From 939b8152d1ffc4037fd13d353ea91d7cd4964929 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 20 Jul 2026 21:01:43 -0400 Subject: [PATCH 1/5] feat(processing): add job execution and backend contract --- .env.example | 4 - .github/workflows/release.yml | 4 +- .gitignore | 4 - backend-contract/README.md | 139 ++ .../negative/constraint-violation.json | 26 + .../fixtures/negative/unknown-field-kind.json | 24 + .../task-spec/synthetic-all-kinds.json | 86 + .../task-spec/synthetic-bounds-enum.json | 65 + .../fixtures/wire/handle-roundtrip.json | 50 + .../wire/input-value.dicom-series.json | 9 + .../fixtures/wire/input-value.labelmap.json | 7 + .../wire/input-value.single-file.json | 7 + .../fixtures/wire/intent.add-base-image.json | 6 + .../fixtures/wire/intent.add-layer.json | 6 + .../intent.add-segment-group.embedded.json | 10 + ...ntent.add-segment-group.with-segments.json | 43 + .../fixtures/wire/intent.restore-state.json | 6 + .../fixtures/wire/intent.unknown.json | 6 + .../fixtures/wire/job-history-detail.json | 5 + .../fixtures/wire/job-history-page.json | 14 + .../fixtures/wire/job-history-summary.json | 16 + .../fixtures/wire/job-results.error.json | 6 + .../fixtures/wire/job-results.missing.json | 12 + .../fixtures/wire/stage-input.labelmap.json | 12 + .../fixtures/wire/status.cancelled.json | 5 + .../fixtures/wire/status.error-tail.json | 6 + .../fixtures/wire/status.error.json | 5 + .../fixtures/wire/status.pending.json | 5 + .../fixtures/wire/status.running.json | 6 + .../fixtures/wire/status.success.json | 6 + .../generated/input-value.schema.json | 23 + .../generated/job-history-detail.schema.json | 28 + .../generated/job-history-page.schema.json | 117 + .../generated/job-history-summary.schema.json | 94 + .../generated/job-results-error.schema.json | 65 + .../generated/job-results.schema.json | 332 +++ .../generated/neutral-job-status.schema.json | 148 ++ backend-contract/generated/openapi.json | 2040 +++++++++++++++++ .../generated/result-intent.schema.json | 306 +++ .../stage-input-descriptor.schema.json | 44 + .../generated/task-spec.schema.json | 401 ++++ backend-contract/index.ts | 13 + backend-contract/package.json | 5 + .../__tests__/generated-schema.spec.ts | 24 + .../processing/__tests__/loadFixtures.ts | 36 + .../processing/__tests__/openapi.spec.ts | 176 ++ .../processing/__tests__/task-spec.spec.ts | 170 ++ .../processing/__tests__/wire.spec.ts | 329 +++ backend-contract/processing/index.ts | 14 + backend-contract/processing/openapi.ts | 518 +++++ backend-contract/processing/schema-json.ts | 56 + backend-contract/processing/task-spec.ts | 247 ++ backend-contract/processing/wire.ts | 317 +++ .../scripts/generate-json-schema.ts | 27 + backend-contract/scripts/generate-openapi.ts | 25 + backend-contract/scripts/sync-backend.sh | 89 + backend-contract/scripts/verify-backend.sh | 67 + docs/authentication.md | 4 + eslint.config.js | 85 + package-lock.json | 7 +- package.json | 5 +- .../__tests__/loadDataSourcesNotices.spec.ts | 106 + src/actions/loadUserFiles.ts | 29 +- src/components/App.vue | 18 +- src/components/ModulePanel.vue | 65 +- src/components/SegmentGroupControls.vue | 19 + src/components/__tests__/latentGating.spec.ts | 129 ++ src/composables/onImageDeleted.ts | 30 +- src/composables/useGlobalErrorHook.ts | 8 + src/core/progressiveImage.ts | 8 + src/core/streaming/requestPool.ts | 3 +- src/env.d.ts | 1 - src/io/__tests__/originGate.spec.ts | 40 + src/io/__tests__/segNrrdMetadata.spec.ts | 250 ++ .../__tests__/configRecognition.spec.ts | 132 ++ .../import/__tests__/degradedRestore.spec.ts | 79 + .../processingConfigInjection.spec.ts | 246 ++ .../__tests__/restoreStateFileLeaves.spec.ts | 107 + .../__tests__/restoreStateIdCollision.spec.ts | 226 ++ .../import/__tests__/stateFileLeaves.spec.ts | 107 + src/io/import/configJson.ts | 87 +- src/io/import/dataSource.ts | 34 + src/io/import/importDataSources.ts | 60 +- .../__tests__/restoreStateFile.spec.ts | 146 -- src/io/import/processors/handleConfig.ts | 53 +- src/io/import/processors/importSingleFile.ts | 7 +- src/io/import/processors/restoreStateFile.ts | 118 +- src/io/originGate.ts | 31 + src/io/readers.ts | 15 +- src/io/segNrrdMetadata.ts | 160 ++ .../__tests__/segmentGroupSource.spec.ts | 92 + .../__tests__/serializeResilience.spec.ts | 179 ++ src/io/state-file/migrations.ts | 14 +- src/io/state-file/schema.ts | 28 +- src/io/state-file/serialize.ts | 264 ++- src/processing/__tests__/applyResults.spec.ts | 497 ++++ src/processing/__tests__/config.spec.ts | 216 ++ .../__tests__/jobResultReview.spec.ts | 97 + src/processing/__tests__/store.spec.ts | 1669 ++++++++++++++ src/processing/applyResults.ts | 271 +++ src/processing/components/JobList.vue | 872 +++++++ src/processing/components/JobsModule.vue | 715 ++++++ src/processing/components/TaskForm.vue | 188 ++ src/processing/components/TaskPicker.vue | 35 + .../components/__tests__/JobsModule.spec.ts | 371 +++ .../components/widgets/BooleanWidget.vue | 23 + .../components/widgets/BoundsWidget.vue | 46 + .../components/widgets/EnumerationWidget.vue | 33 + .../components/widgets/FileWidget.vue | 82 + .../components/widgets/NumberWidget.vue | 78 + .../components/widgets/StringWidget.vue | 23 + .../widgets/__tests__/NumberWidget.spec.ts | 99 + src/processing/config.ts | 84 + .../engine/__tests__/bounds.spec.ts | 58 + .../engine/__tests__/createProvider.spec.ts | 43 + .../engine/__tests__/formModel.spec.ts | 173 ++ .../engine/__tests__/jobHistory.spec.ts | 162 ++ .../engine/__tests__/mintInput.spec.ts | 297 +++ .../engine/__tests__/mintLabelmap.spec.ts | 342 +++ .../engine/__tests__/resultToIntent.spec.ts | 40 + .../engine/__tests__/transport.spec.ts | 253 ++ src/processing/engine/__tests__/wire.spec.ts | 314 +++ src/processing/engine/bounds.ts | 58 + src/processing/engine/createProvider.ts | 29 + src/processing/engine/formModel.ts | 201 ++ src/processing/engine/index.ts | 16 + src/processing/engine/jobHistory.ts | 146 ++ src/processing/engine/mintInput.ts | 228 ++ src/processing/engine/mintLabelmap.ts | 232 ++ src/processing/engine/resultToIntent.ts | 34 + src/processing/engine/taskSpec.ts | 37 + src/processing/engine/transport.ts | 233 ++ src/processing/engine/wire.ts | 206 ++ src/processing/index.ts | 42 + src/processing/jobResultReview.ts | 57 + src/processing/store.ts | 1023 +++++++++ src/processing/types.ts | 225 ++ .../annotationToolImageDelete.spec.ts | 134 ++ .../__tests__/datasetRemoveCascade.spec.ts | 167 ++ src/store/__tests__/datasets-layers.spec.ts | 79 + src/store/__tests__/remote-save-state.spec.ts | 158 ++ .../segmentGroupDescriptorlessParity.spec.ts | 222 ++ .../segmentGroupRestoreResilience.spec.ts | 337 +++ src/store/datasets-images.ts | 7 +- src/store/datasets-layers.ts | 8 +- src/store/datasets.ts | 15 + src/store/image-cache.ts | 9 +- src/store/remote-save-state.ts | 64 +- src/store/segmentGroups.ts | 292 ++- src/store/tools/crop.ts | 9 + src/store/tools/paint.ts | 16 + src/store/tools/useAnnotationTool.ts | 12 + src/store/views.ts | 17 +- src/utils/__tests__/fetch.spec.ts | 117 + src/utils/fetch.ts | 92 +- src/utils/path.ts | 5 +- src/utils/token.ts | 18 +- tsconfig.json | 1 + vite.config.ts | 3 + 159 files changed, 20583 insertions(+), 350 deletions(-) create mode 100644 backend-contract/README.md create mode 100644 backend-contract/fixtures/negative/constraint-violation.json create mode 100644 backend-contract/fixtures/negative/unknown-field-kind.json create mode 100644 backend-contract/fixtures/task-spec/synthetic-all-kinds.json create mode 100644 backend-contract/fixtures/task-spec/synthetic-bounds-enum.json create mode 100644 backend-contract/fixtures/wire/handle-roundtrip.json create mode 100644 backend-contract/fixtures/wire/input-value.dicom-series.json create mode 100644 backend-contract/fixtures/wire/input-value.labelmap.json create mode 100644 backend-contract/fixtures/wire/input-value.single-file.json create mode 100644 backend-contract/fixtures/wire/intent.add-base-image.json create mode 100644 backend-contract/fixtures/wire/intent.add-layer.json create mode 100644 backend-contract/fixtures/wire/intent.add-segment-group.embedded.json create mode 100644 backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json create mode 100644 backend-contract/fixtures/wire/intent.restore-state.json create mode 100644 backend-contract/fixtures/wire/intent.unknown.json create mode 100644 backend-contract/fixtures/wire/job-history-detail.json create mode 100644 backend-contract/fixtures/wire/job-history-page.json create mode 100644 backend-contract/fixtures/wire/job-history-summary.json create mode 100644 backend-contract/fixtures/wire/job-results.error.json create mode 100644 backend-contract/fixtures/wire/job-results.missing.json create mode 100644 backend-contract/fixtures/wire/stage-input.labelmap.json create mode 100644 backend-contract/fixtures/wire/status.cancelled.json create mode 100644 backend-contract/fixtures/wire/status.error-tail.json create mode 100644 backend-contract/fixtures/wire/status.error.json create mode 100644 backend-contract/fixtures/wire/status.pending.json create mode 100644 backend-contract/fixtures/wire/status.running.json create mode 100644 backend-contract/fixtures/wire/status.success.json create mode 100644 backend-contract/generated/input-value.schema.json create mode 100644 backend-contract/generated/job-history-detail.schema.json create mode 100644 backend-contract/generated/job-history-page.schema.json create mode 100644 backend-contract/generated/job-history-summary.schema.json create mode 100644 backend-contract/generated/job-results-error.schema.json create mode 100644 backend-contract/generated/job-results.schema.json create mode 100644 backend-contract/generated/neutral-job-status.schema.json create mode 100644 backend-contract/generated/openapi.json create mode 100644 backend-contract/generated/result-intent.schema.json create mode 100644 backend-contract/generated/stage-input-descriptor.schema.json create mode 100644 backend-contract/generated/task-spec.schema.json create mode 100644 backend-contract/index.ts create mode 100644 backend-contract/package.json create mode 100644 backend-contract/processing/__tests__/generated-schema.spec.ts create mode 100644 backend-contract/processing/__tests__/loadFixtures.ts create mode 100644 backend-contract/processing/__tests__/openapi.spec.ts create mode 100644 backend-contract/processing/__tests__/task-spec.spec.ts create mode 100644 backend-contract/processing/__tests__/wire.spec.ts create mode 100644 backend-contract/processing/index.ts create mode 100644 backend-contract/processing/openapi.ts create mode 100644 backend-contract/processing/schema-json.ts create mode 100644 backend-contract/processing/task-spec.ts create mode 100644 backend-contract/processing/wire.ts create mode 100644 backend-contract/scripts/generate-json-schema.ts create mode 100644 backend-contract/scripts/generate-openapi.ts create mode 100755 backend-contract/scripts/sync-backend.sh create mode 100755 backend-contract/scripts/verify-backend.sh create mode 100644 src/actions/__tests__/loadDataSourcesNotices.spec.ts create mode 100644 src/components/__tests__/latentGating.spec.ts create mode 100644 src/io/__tests__/originGate.spec.ts create mode 100644 src/io/__tests__/segNrrdMetadata.spec.ts create mode 100644 src/io/import/__tests__/configRecognition.spec.ts create mode 100644 src/io/import/__tests__/degradedRestore.spec.ts create mode 100644 src/io/import/__tests__/processingConfigInjection.spec.ts create mode 100644 src/io/import/__tests__/restoreStateFileLeaves.spec.ts create mode 100644 src/io/import/__tests__/restoreStateIdCollision.spec.ts create mode 100644 src/io/import/__tests__/stateFileLeaves.spec.ts delete mode 100644 src/io/import/processors/__tests__/restoreStateFile.spec.ts create mode 100644 src/io/originGate.ts create mode 100644 src/io/state-file/__tests__/segmentGroupSource.spec.ts create mode 100644 src/io/state-file/__tests__/serializeResilience.spec.ts create mode 100644 src/processing/__tests__/applyResults.spec.ts create mode 100644 src/processing/__tests__/config.spec.ts create mode 100644 src/processing/__tests__/jobResultReview.spec.ts create mode 100644 src/processing/__tests__/store.spec.ts create mode 100644 src/processing/applyResults.ts create mode 100644 src/processing/components/JobList.vue create mode 100644 src/processing/components/JobsModule.vue create mode 100644 src/processing/components/TaskForm.vue create mode 100644 src/processing/components/TaskPicker.vue create mode 100644 src/processing/components/__tests__/JobsModule.spec.ts create mode 100644 src/processing/components/widgets/BooleanWidget.vue create mode 100644 src/processing/components/widgets/BoundsWidget.vue create mode 100644 src/processing/components/widgets/EnumerationWidget.vue create mode 100644 src/processing/components/widgets/FileWidget.vue create mode 100644 src/processing/components/widgets/NumberWidget.vue create mode 100644 src/processing/components/widgets/StringWidget.vue create mode 100644 src/processing/components/widgets/__tests__/NumberWidget.spec.ts create mode 100644 src/processing/config.ts create mode 100644 src/processing/engine/__tests__/bounds.spec.ts create mode 100644 src/processing/engine/__tests__/createProvider.spec.ts create mode 100644 src/processing/engine/__tests__/formModel.spec.ts create mode 100644 src/processing/engine/__tests__/jobHistory.spec.ts create mode 100644 src/processing/engine/__tests__/mintInput.spec.ts create mode 100644 src/processing/engine/__tests__/mintLabelmap.spec.ts create mode 100644 src/processing/engine/__tests__/resultToIntent.spec.ts create mode 100644 src/processing/engine/__tests__/transport.spec.ts create mode 100644 src/processing/engine/__tests__/wire.spec.ts create mode 100644 src/processing/engine/bounds.ts create mode 100644 src/processing/engine/createProvider.ts create mode 100644 src/processing/engine/formModel.ts create mode 100644 src/processing/engine/index.ts create mode 100644 src/processing/engine/jobHistory.ts create mode 100644 src/processing/engine/mintInput.ts create mode 100644 src/processing/engine/mintLabelmap.ts create mode 100644 src/processing/engine/resultToIntent.ts create mode 100644 src/processing/engine/taskSpec.ts create mode 100644 src/processing/engine/transport.ts create mode 100644 src/processing/engine/wire.ts create mode 100644 src/processing/index.ts create mode 100644 src/processing/jobResultReview.ts create mode 100644 src/processing/store.ts create mode 100644 src/processing/types.ts create mode 100644 src/store/__tests__/annotationToolImageDelete.spec.ts create mode 100644 src/store/__tests__/datasetRemoveCascade.spec.ts create mode 100644 src/store/__tests__/datasets-layers.spec.ts create mode 100644 src/store/__tests__/remote-save-state.spec.ts create mode 100644 src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts create mode 100644 src/store/__tests__/segmentGroupRestoreResilience.spec.ts create mode 100644 src/utils/__tests__/fetch.spec.ts 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/.gitignore b/.gitignore index dd9be9f03..a072b660a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,3 @@ bundle-analysis.html reports *.log .tmp/ - -.github/copilot -CLAUDE.md -.claude/ diff --git a/backend-contract/README.md b/backend-contract/README.md new file mode 100644 index 000000000..20e335f8c --- /dev/null +++ b/backend-contract/README.md @@ -0,0 +1,139 @@ +# backend-contract + +> **Status: private draft `0.x`.** The artifact version is the OpenAPI +> `info.version` in `generated/openapi.json`, mirrored by `package.json` and kept +> in lockstep by a drift test. This is a private, versioned draft with exactly one +> known consumer (girder_volview, vendored via `sync-backend.sh`); it carries no +> stability promise and no distribution channel. The shapes may change any day. + +The neutral VolView backend contract, published as a self-contained, +backend-decoupled artifact. It defines the **processing** surface — task +discovery, inputs, the job lifecycle, result intents, and personal job history. +The VolView client and any server-side backend (girder_volview today) build +against the shapes defined here; no backend speaks them natively — a backend +**translates** its native task format into this one neutral spec. + +**Bring a new backend online = implement the [OpenAPI](#the-neutral-rest-surface-openapi) +and validate against the fixtures + generated schemas; zero VolView client +change.** Adding a backend is a _conformance exercise_, not a reverse-engineering +of girder_volview: girder_volview is one _consumer_ of this package, not its +owner. It vendors the fixtures + generated schemas (`tests/contract/`) and +validates against them; it defines nothing here. + +## Layout + +``` +backend-contract/ + index.ts top-level barrel; re-exports ./processing + processing/ the client↔backend processing contract + task-spec.ts VolView's own zod task-spec schema + wire.ts neutral wire shapes: input value, job status, job history, + and the result-intent vocabulary + openapi.ts the neutral REST surface as an OpenAPI 3.1 document, built + single-source (wire component schemas injected from the zod + codegen) + schema-json.ts zod -> JSON Schema codegen + index.ts re-exports the schemas + types (NOT the codegen/openapi — + those are imported directly by scripts + tests) + __tests__/ vitest: every fixture validates; negatives fail; the + generated schema + openapi stay in sync with the zod source + generated/ checked-in artifacts the backend validates against: + *.schema.json (one per wire schema) + openapi.json + fixtures/ + task-spec/ synthetic golden task specs (all field kinds + a + bounds/enum/UI-hints spec) — no backend-specific source + format (e.g. Slicer XML) lives here; translating a native + task format into this neutral spec is a backend concern + negative/ payloads that MUST fail validation (fail closed) + 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 + sync-backend.sh vendors fixtures + generated/ into a backend repo +``` + +## The single normative definition + +The **zod sources here are the one normative definition** of the contract. +JSON Schema is deliberately NOT the wire contract — it describes validity but not +rendering, and there is exactly one producer (our backend) and one consumer (our +renderer). 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. + +## 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, no +`folderId`, no file ids, no `JobStatus` enum, no proxiable-URL shape. A reviewer +can enumerate what a non-Girder backend must implement **without reading +girder_volview source**: + +| operation | method + neutral 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: the execution record, its results, and its 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` | + +> Naming note: `listJobHistory` / `JobHistorySummary` / `deleteJob` are the +> **job-history** shapes — the user's personal job history, a processing +> concept. + +The component schemas are the wire schemas above (`TaskSpec`, `InputValue`, +`StageInputDescriptor`, `NeutralJobStatus`, `ResultIntent`, `JobHistorySummary`, +…), injected from the same zod codegen, so the published surface can never drift +from the normative definition. The lifecycle is **poll-only** (`getJob`); push +(SSE) is an additive backend-only enhancement, never a neutral client +requirement, so it is not described here. Job-addressed routes (`getJob` / +`getJobResults` / `cancelJob`) 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 declared outputs with +no recorded id plus recorded outputs whose file is no longer readable. + +## Job-state names + +The neutral job states are `pending | running | success | error | cancelled` — +the names the backend projects and the client store consumes at runtime. Girder's +native job status maps onto these with no translation layer, so the canonical +schema is named _to_ the runtime. A backend-side status-conformance test +(girder_volview `tests/test_status_conformance.py`) validates its projected +status against the generated `neutral-job-status` schema so this can't silently +drift. + +## Versioning + +Two versions live here and they turn on **separate clocks**: + +- The **artifact version** — `package.json` `version` (`private: true`) and the + OpenAPI `info.version`, kept in lockstep by a drift test + (`processing/__tests__/openapi.spec.ts`). It versions _this package as a + published thing_: a draft `0.x` carrying no stability promise. +- The **shape versions** — `INTENT_VOCABULARY_VERSION` (`processing/wire.ts`) and + the task-spec `specVersion`. These version the _wire vocabulary_ so a producer + and the applier can negotiate additive compatibility; they are NOT the artifact + version. + +## 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 +backend-contract/scripts/sync-backend.sh [BACKEND_REPO] # regen + vendor into a backend repo +``` + +`sync-backend.sh` is the **single writer** of a backend's vendored copy (never +hand-edit `tests/contract/`); it regenerates first, then copies, so a backend's +copy is never stale. The vitest 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. 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/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/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..60e6f3b10 --- /dev/null +++ b/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json @@ -0,0 +1,10 @@ +{ + "id": "6600000000000000000000e2", + "intent": "add-segment-group", + "url": "/api/v1/file/6600000000000000000000e2/proxiable/threshold.seg.nrrd", + "name": "threshold.seg.nrrd", + "source": { + "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..1fdf5253d --- /dev/null +++ b/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json @@ -0,0 +1,43 @@ +{ + "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": { + "jobId": "job-abc123", + "outputId": "outputLabelmap" + } +} diff --git a/backend-contract/fixtures/wire/intent.restore-state.json b/backend-contract/fixtures/wire/intent.restore-state.json new file mode 100644 index 000000000..40741ffb4 --- /dev/null +++ b/backend-contract/fixtures/wire/intent.restore-state.json @@ -0,0 +1,6 @@ +{ + "id": "6600000000000000000000d3", + "intent": "restore-state", + "url": "/api/v1/file/6600000000000000000000d3/proxiable/session.volview.zip", + "name": "session.volview.zip" +} 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..129f376f8 --- /dev/null +++ b/backend-contract/generated/input-value.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "format": { + "type": "string" + }, + "uris": { + "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..4518683dc --- /dev/null +++ b/backend-contract/generated/job-history-detail.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobId": { + "type": "string" + }, + "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..44ee5340e --- /dev/null +++ b/backend-contract/generated/job-history-page.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "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..30ef66f57 --- /dev/null +++ b/backend-contract/generated/job-history-summary.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobId": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "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..705ba9f5b --- /dev/null +++ b/backend-contract/generated/job-results.schema.json @@ -0,0 +1,332 @@ +{ + "$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": { + "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": { + "jobId": { + "type": "string" + }, + "outputId": { + "type": "string" + } + }, + "required": [ + "jobId", + "outputId" + ], + "additionalProperties": false + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "restore-state" + }, + "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": {}, + "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..9722d2efc --- /dev/null +++ b/backend-contract/generated/neutral-job-status.schema.json @@ -0,0 +1,148 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "jobId": { + "type": "string" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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..cb043ede2 --- /dev/null +++ b/backend-contract/generated/openapi.json @@ -0,0 +1,2040 @@ +{ + "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" + } + } + ], + "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" + } + } + ], + "requestBody": { + "required": false, + "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" + } + } + ], + "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 through Poll until the job reaches a terminal state. Job-addressed: keyed by the job own access control — no context in the path.", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "Opaque job identifier.", + "schema": { + "type": "string" + } + } + ], + "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" + } + } + ], + "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" + } + } + ], + "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" + } + } + ], + "responses": { + "200": { + "description": "The projected job status after the cancel attempt.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeutralJobStatus" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "TaskSpec": { + "type": "object", + "properties": { + "specVersion": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "int" + }, + "id": { + "type": "string" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "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" + }, + "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": { + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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": { + "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": { + "jobId": { + "type": "string" + }, + "outputId": { + "type": "string" + } + }, + "required": [ + "jobId", + "outputId" + ], + "additionalProperties": false + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "restore-state" + }, + "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": {}, + "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" + }, + "taskId": { + "type": "string" + }, + "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": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "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 + }, + "JobHistoryDetail": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + }, + "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": { + "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": { + "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": { + "jobId": { + "type": "string" + }, + "outputId": { + "type": "string" + } + }, + "required": [ + "jobId", + "outputId" + ], + "additionalProperties": false + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "restore-state" + }, + "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": {}, + "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 + }, + "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 — including `dockerImage`, a display-only implementation label with no neutral semantics.", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dockerImage": { + "type": "string", + "description": "Optional advisory implementation label shown in the picker. Display-only: a backend MAY omit it and the client never dispatches on it." + }, + "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" + } + ] + } + } + }, + "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" + }, + "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..017061a9a --- /dev/null +++ b/backend-contract/generated/result-intent.schema.json @@ -0,0 +1,306 @@ +{ + "$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": { + "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": { + "jobId": { + "type": "string" + }, + "outputId": { + "type": "string" + } + }, + "required": [ + "jobId", + "outputId" + ], + "additionalProperties": false + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "restore-state" + }, + "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": {}, + "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..af9d955ee --- /dev/null +++ b/backend-contract/generated/task-spec.schema.json @@ -0,0 +1,401 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "specVersion": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "int" + }, + "id": { + "type": "string" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "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" + }, + "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..0819cd902 --- /dev/null +++ b/backend-contract/index.ts @@ -0,0 +1,13 @@ +// --------------------------------------------------------------------------- +// backend-contract — the neutral VolView backend contract, published as an +// artifact. This release ships the PROCESSING slice only (task discovery, +// inputs, job lifecycle, results). Contract stays draft 0.x. +// +// 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..5650bf4b0 --- /dev/null +++ b/backend-contract/processing/__tests__/generated-schema.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { generateJsonSchemas, GENERATED_SCHEMA_NAMES } from '../schema-json'; + +// 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]); + }); +}); 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..37d66171f --- /dev/null +++ b/backend-contract/processing/__tests__/openapi.spec.ts @@ -0,0 +1,176 @@ +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, + OPENAPI_INTENT_VOCABULARY, +} from '../openapi'; +import { JOB_STATES } from '../wire'; + +// The published OpenAPI is the backend's obligation surface. +// These are its guards: it stays in sync with the single zod source, covers +// exactly the neutral client-invoked endpoints, resolves every $ref, and — the +// whole point — leaks NOTHING Girder-specific (AC1: a reviewer 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 (Seams 1-3). + 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); + }); + }); +}); + +// --------------------------------------------------------------------------- +// $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); + OPENAPI_INTENT_VOCABULARY.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: 'slicer mention', re: /slicer/i }, + { label: 'backend task xml', re: /\bxml\b/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..47ffbd38f --- /dev/null +++ b/backend-contract/processing/__tests__/task-spec.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import { + SPEC_VERSION, + taskSpecSchema, + taskParameterSchema, + 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); + }); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); + + 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); + }); +}); diff --git a/backend-contract/processing/__tests__/wire.spec.ts b/backend-contract/processing/__tests__/wire.spec.ts new file mode 100644 index 000000000..12d7e5727 --- /dev/null +++ b/backend-contract/processing/__tests__/wire.spec.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest'; + +import { + JOB_STATES, + RESULT_INTENTS, + INTENT_VOCABULARY_VERSION, + isKnownIntent, + inputValueSchema, + stageInputDescriptorSchema, + neutralJobStatusSchema, + resultIntentSchema, + jobHistoryPageSchema, + jobHistorySummarySchema, + jobHistoryDetailSchema, + jobResultsSchema, + jobResultsErrorSchema, +} from '../wire'; +import { 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(); + }); +}); + +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', () => { + // Runtime names: the backend projects and the + // client store consumes these; the canonical schema is named TO them. + 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 the pre-reconcile spellings and are now + // rejected; the runtime names (`pending`/`success`/`error`) are the valid five. + expect( + neutralJobStatusSchema.safeParse({ jobId: 'j', state: 'queued' }).success + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Result intents +// --------------------------------------------------------------------------- + +describe('result intent fixtures', () => { + it('exports vocabulary version 1 and the exactly-four state intents', () => { + expect(INTENT_VOCABULARY_VERSION).toBe(1); + expect([...RESULT_INTENTS]).toEqual([ + 'add-base-image', + 'add-layer', + 'add-segment-group', + 'restore-state', + ]); + 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.restore-state', + '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({ + 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('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(isKnownIntent(parsed.intent as string)).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('recognizes only the four state intents via isKnownIntent', () => { + expect(isKnownIntent('add-segment-group')).toBe(true); + expect(isKnownIntent('download')).toBe(false); + expect(isKnownIntent('attach-segment-group')).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); + }); +}); + +// --------------------------------------------------------------------------- +// 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/index.ts b/backend-contract/processing/index.ts new file mode 100644 index 000000000..749e3ee9b --- /dev/null +++ b/backend-contract/processing/index.ts @@ -0,0 +1,14 @@ +// --------------------------------------------------------------------------- +// 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'; diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts new file mode 100644 index 000000000..6c32017df --- /dev/null +++ b/backend-contract/processing/openapi.ts @@ -0,0 +1,518 @@ +// --------------------------------------------------------------------------- +// 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). +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import { generateJsonSchemas, type GeneratedSchemaName } from './schema-json'; +import { INTENT_VOCABULARY_VERSION, RESULT_INTENTS, JOB_STATES } from './wire'; +import { SPEC_VERSION } from './task-spec'; + +// --------------------------------------------------------------------------- +// 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 wireComponentSchemas = (): Record => { + const generated = generateJsonSchemas(); + return Object.fromEntries( + (Object.keys(WIRE_COMPONENTS) as GeneratedSchemaName[]).map((name) => [ + WIRE_COMPONENTS[name], + stripDialect(generated[name]), + ]) + ); +}; + +// --------------------------------------------------------------------------- +// 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 => ({ + // Advisory display metadata for the task picker. The backend emits it and the + // engine passes it through with no schema, so only `id`/`title` are required + // and additional advisory hints (e.g. a category or image label) are allowed. + 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 — including `dockerImage`, a display-only ' + + 'implementation label with no neutral semantics.', + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + dockerImage: { + type: 'string', + description: + 'Optional advisory implementation label shown in the picker. ' + + 'Display-only: a backend MAY omit it and the client never dispatches ' + + 'on it.', + }, + category: { type: 'array', items: { type: 'string' } }, + }, + required: ['id', 'title'], + additionalProperties: true, + }, + + // The submit body. Each bound value is either an input value + // (`{ type, format?, uris }`) or a plain scalar/list parameter. + 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' }, + ], + }, + }, + }, + additionalProperties: false, + }, + + // The submit response: an opaque job id the client polls. `status` is the + // optional born-terminal fast-path (a synchronous backend may return an + // already-terminal status; the client applies it without ever polling). + 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' }, + status: ref('NeutralJobStatus'), + }, + required: ['jobId'], + additionalProperties: false, + }, + + // The staging response: the backend-minted download URIs for the client-held + // bytes it POSTed. At least one URI — the client constructs none itself, so an + // empty response fails closed. + 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: { type: 'string' }, +} as const; + +const jobIdParam = { + name: 'jobId', + in: 'path', + required: true, + description: 'Opaque job identifier.', + schema: { type: 'string' }, +} 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: false, + 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 through " + + 'Poll until the job reaches a terminal state. Job-addressed: keyed by the job ' + + '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. 1.0 is stamped only when a second backend's shim passes + // the conformance kit. + 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(), + }, + }, +}); + +// The neutral result-intent vocabulary + state names, re-exported so tests and +// docs can assert the published document stays in lockstep with the source. +export const OPENAPI_INTENT_VOCABULARY = RESULT_INTENTS; +export const OPENAPI_JOB_STATES = JOB_STATES; + +// 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..abdb6b58c --- /dev/null +++ b/backend-contract/processing/schema-json.ts @@ -0,0 +1,56 @@ +// --------------------------------------------------------------------------- +// 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 JSON Schema; `unrepresentable: 'any'` drops them from the +// generated structural schema. Those constraints stay the zod side's extra +// rigor (exercised by the negative constraint-violation fixture in vitest). +// --------------------------------------------------------------------------- + +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; + +export const generateJsonSchemas = (): Record => + Object.fromEntries( + Object.entries(schemas).map(([name, schema]) => [ + name, + 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..f21e45db1 --- /dev/null +++ b/backend-contract/processing/task-spec.ts @@ -0,0 +1,247 @@ +// --------------------------------------------------------------------------- +// 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. +// +// JSON Schema is deliberately NOT the wire contract: it describes validity +// but not rendering, and there is exactly one producer (our backend) and one +// consumer (our renderer). +// +// House rules: functional style; `type`, not `interface`. Additive-only — +// new fields (guidance / interactive semantics, future extensions) must +// extend this schema AGAINST `specVersion`, never mutate it. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; + +// 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(); + +// 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; maps +// from Slicer XML ``). +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 negative fixture exercises this). 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 with no Slicer-XML source — the renderer +// picks a default widget from `kind` when it is absent. +const paramCommon = { + id: z.string(), + 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: Slicer integer/float enumerations emit +// numeric members (`` etc.), and coercing them to strings +// at the boundary would lose the type the CLI 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 (absent/`scalar` `` → `["image"]`, +// `` → `["labelmap"]`; the backend authors the mapping). +const sourceRefParam = z.object({ + kind: z.literal('sourceRef'), + ...paramCommon, + accepts: z.array(typeTagSchema).min(1), +}); + +// Imaging-native field kind: an axis-aligned world-space box (LPS). +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'; + +// Cross-field constraint checks layered on top of the discriminated union. +// These are the constraints the negative "constraint-violation" fixture +// exercises. JSON Schema cannot express them; they are the zod side's extra +// rigor over the structural JSON-Schema view generated for the backend. +export const taskParameterSchema = parameterUnion + .refine( + (p) => + !( + isNumericKind(p.kind) && + 'min' in p && + 'max' in p && + p.min != null && + p.max != null && + p.min > p.max + ), + { message: 'min must be <= max', path: ['min'] } + ) + .refine( + (p) => + !( + isNumericKind(p.kind) && + 'default' in p && + 'min' in p && + p.default != null && + p.min != null && + p.default < p.min + ), + { message: 'default must be >= min', path: ['default'] } + ) + .refine( + (p) => + !( + isNumericKind(p.kind) && + 'default' in p && + 'max' in p && + p.default != null && + p.max != null && + p.default > p.max + ), + { message: 'default must be <= max', path: ['default'] } + ) + .refine( + (p) => + !(isNumericKind(p.kind) && 'step' in p && p.step != null && p.step <= 0), + { message: 'step must be > 0', path: ['step'] } + ) + .refine( + (p) => + !( + p.kind === 'enum' && + p.default != null && + !p.options.includes(p.default) + ), + { message: 'default must be one of the enum options', path: ['default'] } + ); + +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: z.string(), + title: z.string().optional(), + help: z.string().optional(), + type: typeTagSchema.optional(), + format: z.string().optional(), +}); + +export type VolViewTaskOutput = z.infer; + +// --------------------------------------------------------------------------- +// The task spec +// --------------------------------------------------------------------------- + +export const taskSpecSchema = z.object({ + specVersion: z.number().int(), + id: z.string(), + title: z.string(), + description: z.string().optional(), + parameters: z.array(taskParameterSchema), + outputs: z.array(taskOutputSchema), +}); + +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..8870bd51c --- /dev/null +++ b/backend-contract/processing/wire.ts @@ -0,0 +1,317 @@ +// --------------------------------------------------------------------------- +// 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. +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { typeTagSchema } from './task-spec'; + +// 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). +export const inputValueSchema = z.object({ + type: typeTagSchema, + format: z.string().optional(), + uris: z.array(z.string()), +}); + +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`): girder's native job status maps 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: z.string(), + 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 four state directives the applier understands. +export const RESULT_INTENTS = [ + 'add-base-image', + 'add-layer', + 'add-segment-group', + 'restore-state', +] as const; +export type ResultIntentName = (typeof RESULT_INTENTS)[number]; + +export const isKnownIntent = (intent: string): intent is ResultIntentName => + (RESULT_INTENTS as readonly string[]).includes(intent); + +// 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({ + jobId: z.string(), + outputId: z.string(), +}); +export type ResultSource = z.infer; + +// A single RGBA channel: an integer in [0, 255]. +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 four 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(); + +const restoreState = z + .object({ intent: z.literal('restore-state'), ...resultListItemSchema.shape }) + .passthrough(); + +// The STRICT half of the vocabulary: exactly the four 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, + restoreState, +]); + +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()); + +// One canonical result-row union — every row is a resultListItem that either +// matched a known intent or fell through to the ordinary branch. +export const resultIntentSchema = z.union([ + knownResultIntentSchema, + unknownIntent, +]); + +export type ResultIntent = z.infer; + +// --------------------------------------------------------------------------- +// Complete personal job history +// --------------------------------------------------------------------------- + +export const jobHistorySummarySchema = z + .object({ + jobId: z.string(), + taskId: z.string(), + 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: z.string(), + 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..bc6293115 --- /dev/null +++ b/backend-contract/scripts/generate-json-schema.ts @@ -0,0 +1,27 @@ +// --------------------------------------------------------------------------- +// 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`. `scripts/sync-backend.sh` copies the +// fixtures + generated schemas into girder_volview. +// --------------------------------------------------------------------------- + +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 }); + +// Processing slice only. +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..e73299971 --- /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; `scripts/sync-backend.sh` vendors +// it (alongside the fixtures + generated schemas) into girder_volview. +// --------------------------------------------------------------------------- + +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/backend-contract/scripts/sync-backend.sh b/backend-contract/scripts/sync-backend.sh new file mode 100755 index 000000000..f86a2cedd --- /dev/null +++ b/backend-contract/scripts/sync-backend.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Vendor the backend-contract fixtures + generated JSON Schemas into the +# girder_volview backend so its tests consume the SAME artifacts (one +# normative source in VolView, a synced copy in the backend — never +# hand-edited). +# +# Usage: +# backend-contract/scripts/sync-backend.sh +# BACKEND_REPO (required): path to the girder_volview checkout to vendor into. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +pkg_dir="$(dirname "$here")" +if [ $# -lt 1 ]; then + echo "usage: backend-contract/scripts/sync-backend.sh " >&2 + exit 2 +fi +backend_repo="$1" +dest="$backend_repo/tests/contract" + +if [ ! -d "$backend_repo" ]; then + echo "backend repo not found: $backend_repo" >&2 + exit 1 +fi + +# Regenerate the JSON Schemas + the published OpenAPI first so the copy is never +# stale (both land in generated/, both are vendored below). One entry point wraps +# BOTH generators so the pipeline cannot be run half-way. +(cd "$pkg_dir/.." && npm run --silent contract:generate) + +# Refuse a dirty sync BEFORE mutating the destination. A dirty contract subtree +# would vendor artifacts that cannot be reconstructed from client_git_sha, +# silently breaking provenance. Checking the CANONICAL subtree here — after +# regeneration, so a stale checked-in generated/ also trips it, and before any +# rm/cp — guarantees a refused sync leaves the backend tree untouched (and lets +# client_git_dirty below be a constant false). +if [ -n "$(git -C "$pkg_dir" status --porcelain -- "$pkg_dir")" ]; then + echo "backend-contract subtree has uncommitted changes; commit (and regen) before syncing" >&2 + git -C "$pkg_dir" status --porcelain -- "$pkg_dir" >&2 + exit 1 +fi + +mkdir -p "$dest" +rm -rf "$dest/fixtures" "$dest/generated" +cp -R "$pkg_dir/fixtures" "$dest/fixtures" +cp -R "$pkg_dir/generated" "$dest/generated" + +# Write a content-addressed manifest over EVERYTHING copied so the vendored copy +# is tamper-evident: the backend's test_contract_manifest re-hashes +# these files against it, catching a hand-edited fixture AND a stale/partial sync +# (a file present-but-unlisted or listed-but-missing both fail the re-hash). The +# manifest itself is excluded (it lives at the tests/contract root, outside the +# copied subtrees). Sorted for a deterministic, diff-friendly manifest. +( + cd "$dest" + LC_ALL=C find fixtures generated -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum > MANIFEST.sha256 +) + +# --- Provenance stamp: make the vendored copy self-describing so a +# reader (and the backend's own test_contract_source) can see WHICH client commit +# / version it was synced from, and self-certify the tree against a single digest. +# This does NOT by itself prove the copy is CURRENT -- that is the client's +# verify-backend.sh step, the only checker that can see both trees. Written OUTSIDE +# the manifested subtrees, so it is not covered by MANIFEST.sha256 (which hashes +# only those subtrees); its own integrity rides tree_sha256 below. +contract_version="$(node -p "require('$dest/generated/openapi.json').info.version")" +spec_version="$(grep -oE 'SPEC_VERSION = [0-9]+' "$pkg_dir/processing/task-spec.ts" | grep -oE '[0-9]+')" +intent_version="$(grep -oE 'INTENT_VOCABULARY_VERSION = [0-9]+' "$pkg_dir/processing/wire.ts" | grep -oE '[0-9]+')" +client_sha="$(git -C "$pkg_dir" rev-parse --short HEAD)" +# Always false: the pre-copy guard above already refused any dirty subtree, so a +# sync that reaches this point is provably clean. +client_dirty=false +# tree_sha256 = sha256 of MANIFEST.sha256, which already provably equals the +# copied tree, so hashing it transitively certifies fixtures/ + generated/. +tree_sha256="$(sha256sum "$dest/MANIFEST.sha256" | cut -d' ' -f1)" + +cat > "$dest/SOURCE.txt" < $dest" diff --git a/backend-contract/scripts/verify-backend.sh b/backend-contract/scripts/verify-backend.sh new file mode 100755 index 000000000..582db5c8b --- /dev/null +++ b/backend-contract/scripts/verify-backend.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# The ACTUAL cross-repo drift guard. The backend's own tests can only +# self-certify its vendored copy (present, well-formed, internally consistent); +# only the client (the normative source) can prove that copy is CURRENT, and only +# where the backend tree is reachable. Regenerate the client contract, then diff +# the canonical client tree against the backend's vendored copy. A nonzero exit +# means the backend is stale -- someone regenerated the contract without re-running +# sync-backend.sh. +# +# backend-contract/scripts/verify-backend.sh [--no-generate] +# BACKEND_REPO (required): path to the girder_volview checkout to diff against. +# +# --no-generate: skip regeneration and diff the already-checked-in artifacts. +# Use this when running from an INSTALLED npm package (node_modules/volview/ +# backend-contract), where the shipped artifacts are guaranteed fresh by the +# client's own CI (the generated-schema vitest spec + `npm test` on every +# commit), so no zod/tsx toolchain is needed at the consumer. Without it, the +# script regenerates first (the in-repo path, where source can be edited). +# +# CI-enforced: the backend repo's `paired-contract` workflow job checks out an +# explicitly pinned VolView revision alongside this backend and runs this script +# (see girder_volview `.github/workflows/ci.yml`), so a stale vendored copy fails +# CI, not only a local pre-push check. It also remains runnable on a dev machine. +# An explicitly supplied backend path is a required gate: a missing contract tree +# is an error, not a skip. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +pkg_dir="$(dirname "$here")" +generate=1 +if [ "${1:-}" = "--no-generate" ]; then + generate= + shift +fi +if [ $# -lt 1 ]; then + echo "usage: backend-contract/scripts/verify-backend.sh [--no-generate] " >&2 + exit 2 +fi +backend_repo="$1" +dest="$backend_repo/tests/contract" + +if [ ! -d "$dest" ]; then + echo "verify-backend: backend contract tree not present: $dest" >&2 + exit 1 +fi + +# Regenerate so we compare against fresh output, never a stale working copy. +# Skipped under --no-generate (installed-package path): the shipped artifacts are +# already the client's freshly-generated, CI-verified output. +if [ -n "$generate" ]; then + (cd "$pkg_dir/.." && npm run --silent contract:generate) +fi + +status=0 +for sub in fixtures generated; do + if ! diff -r "$pkg_dir/$sub" "$dest/$sub"; then + status=1 + fi +done + +if [ "$status" -ne 0 ]; then + echo "" >&2 + echo "BACKEND CONTRACT IS STALE: the vendored copy differs from the client" >&2 + echo "contract above. Run: backend-contract/scripts/sync-backend.sh" >&2 + exit 1 +fi +echo "verify-backend: backend vendored contract is in sync." diff --git a/docs/authentication.md b/docs/authentication.md index e919b9f61..bb8448970 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -17,12 +17,16 @@ 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. +A `token=` value attaches to **all** requests, including cross-origin ones. This supports the hosted-instance flow where the VolView client is served from one origin (e.g. a static host) while `urls=` points at a data server on another origin. The token is a secret the link's author already possesses, so attaching it cross-origin exposes nothing the link itself did not. + ### `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. +Unlike `token=`, a token fetched via `tokenUrl=` attaches **only** to requests whose origin matches the token endpoint's own origin (plus same-origin requests). Because `tokenUrl=` can mint a token from a cookie-authenticated endpoint, scoping the derived token to that endpoint's origin prevents a crafted link from minting a victim's token and exfiltrating it to an attacker-controlled host via `urls=`. If your `tokenUrl` endpoint and your data server are on different origins, the derived token will not be sent to the data server — use `token=` for that cross-origin shape. + 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] 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-lock.json b/package-lock.json index f9cd2b72b..97604eb99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "volview", "version": "0.0.0", + "dependencies": { + "zod": "^4.1.13" + }, "devDependencies": { "@commitlint/cli": "^21.0.0", "@commitlint/config-conventional": "^21.0.0", @@ -83,8 +86,7 @@ "wdio-html-nice-reporter": "^8.1.7", "wdio-wait-for": "^3.1.0", "webdriverio": "^9.20.1", - "yorkie": "^2.0.0", - "zod": "^4.1.13" + "yorkie": "^2.0.0" } }, "node_modules/@algolia/abtesting": { @@ -22979,7 +22981,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index bd9fc4f66..d96bb7588 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,16 @@ "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", + "contract:verify-backend": "backend-contract/scripts/verify-backend.sh", "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..be9a3d4c4 --- /dev/null +++ b/src/actions/__tests__/loadDataSourcesNotices.spec.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; + +import { loadDataSources } from '@/src/actions/loadUserFiles'; +import useLoadDataStore from '@/src/store/load-data'; +import type { DataSource } from '@/src/io/import/dataSource'; +import { asErrorResult } from '@/src/io/import/common'; + +// --------------------------------------------------------------------------- +// ONE consolidated notice for degraded composed opens: a composed base whose +// fetch fails is already +// counted by completeStateFileRestore's consolidated "Some scene content +// could not be restored" warning — the generic error-styled "Some files +// failed to load" must NOT fire for the same leaf. Only genuinely standalone +// imports keep the generic error. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + importDataSources: vi.fn(), +})); + +vi.mock('@/src/io/import/importDataSources', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, importDataSources: mocks.importDataSources }; +}); + +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 composed leaves', () => { + beforeEach(() => { + setActivePinia(createPinia()); + mocks.importDataSources.mockReset(); + }); + + it('a failed state-file leaf does NOT raise the generic load error', async () => { + const leaf = composedLeaf('ds-a'); + mocks.importDataSources.mockResolvedValue([ + asErrorResult(new Error('fetch failed'), leaf), + ]); + const spy = vi.spyOn(useLoadDataStore(), 'setError'); + + await loadDataSources([leaf]); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('a failed leaf nested under a download chain is still recognized', async () => { + const leaf = composedLeaf('ds-a'); + const nested: DataSource = { + type: 'file', + file: new File([], 'ds-a.nrrd'), + fileType: 'application/octet-stream', + parent: leaf, + }; + mocks.importDataSources.mockResolvedValue([ + asErrorResult(new Error('parse failed'), nested), + ]); + const spy = vi.spyOn(useLoadDataStore(), 'setError'); + + await loadDataSources([leaf]); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('a standalone failed import 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 failure reports ONLY the standalone entries', async () => { + const leaf = composedLeaf('ds-a'); + const source = standaloneSource(); + mocks.importDataSources.mockResolvedValue([ + asErrorResult(new Error('fetch failed'), 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'); + }); +}); diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index 0c283475c..128bedc45 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -4,6 +4,7 @@ import { uriToDataSource, DataSource, getDataSourceName, + findStateFileLeaves, } from '@/src/io/import/dataSource'; import useLoadDataStore from '@/src/store/load-data'; import { useDICOMStore } from '@/src/store/datasets-dicom'; @@ -265,7 +266,7 @@ function loadSegmentations( }); } -function loadDataSources(sources: DataSource[]) { +export function loadDataSources(sources: DataSource[]) { const loadDataStore = useLoadDataStore(); const viewStore = useViewStore(); @@ -314,8 +315,15 @@ function loadDataSources(sources: DataSource[]) { } // else must be primaryDataSource.type === 'model', which are not dealt with here yet } - if (errored.length) { - const errorMessages = (errored as ErrorResult[]).map((errResult) => { + // ONE consolidated notice for degraded composed opens: a failed state-file + // leaf is already counted by the restore's consolidated missing-content + // warning — only genuinely standalone imports surface the generic error here. + const standaloneErrored = (errored as ErrorResult[]).filter( + ({ dataSource }) => findStateFileLeaves(dataSource).length === 0 + ); + + if (standaloneErrored.length) { + const errorMessages = standaloneErrored.map((errResult) => { const { dataSource, error } = errResult; const name = getDataSourceName(dataSource); logError(error); @@ -327,6 +335,11 @@ function loadDataSources(sources: DataSource[]) { loadDataStore.setError(failedError); } + return succeeded.flatMap((result) => + 'dataID' in result && typeof result.dataID === 'string' + ? [result.dataID] + : [] + ); }; const wrapWithLoading = void>(fn: T) => { @@ -334,7 +347,7 @@ function loadDataSources(sources: DataSource[]) { return async function wrapper(...args: any[]) { try { startLoading(); - await fn(...args); + return await fn(...args); } finally { stopLoading(); } @@ -383,16 +396,18 @@ type LoadUrlsParams = { }; export async function loadUrls(params: UrlParams | LoadUrlsParams) { + const loadedIds: string[] = []; if (params.config) { const configUrls = wrapInArray(params.config); - const configSources = urlsToDataSources(configUrls); - await loadDataSources(configSources); + const configSources = urlsToDataSources(configUrls, []); + loadedIds.push(...((await loadDataSources(configSources)) ?? [])); } if (params.urls) { const urls = wrapInArray(params.urls); const names = wrapInArray(params.names ?? []); const sources = urlsToDataSources(urls, names); - await loadDataSources(sources); + loadedIds.push(...((await loadDataSources(sources)) ?? [])); } + return loadedIds; } diff --git a/src/components/App.vue b/src/components/App.vue index c3fb18303..64c0dc3c1 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -58,6 +58,7 @@ import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract'; import { useDisplay } from 'vuetify'; import useLoadDataStore from '@/src/store/load-data'; import { useViewStore } from '@/src/store/views'; +import { useProcessingJobsStore } from '@/src/processing'; import useRemoteSaveStateStore from '@/src/store/remote-save-state'; import AppBar from '@/src/components/AppBar.vue'; import ControlsStrip from '@/src/components/ControlsStrip.vue'; @@ -157,13 +158,24 @@ export default defineComponent({ urlParams = {}; } - onMounted(() => { - loadUrls(urlParams); + onMounted(async () => { + await loadUrls(urlParams); + // 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. + try { + await useProcessingJobsStore().adoptJobHistory(); + } catch (err) { + console.error('Job re-discovery failed', err); + } }); // --- remote save state URL --- // - if (import.meta.env.VITE_ENABLE_REMOTE_SAVE && urlParams.save) { + if (urlParams.save) { const url = Array.isArray(urlParams.save) ? urlParams.save[0] : urlParams.save; diff --git a/src/components/ModulePanel.vue b/src/components/ModulePanel.vue index 11dfe5e8a..19bc287d9 100644 --- a/src/components/ModulePanel.vue +++ b/src/components/ModulePanel.vue @@ -41,9 +41,17 @@ + + diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue new file mode 100644 index 000000000..7041c30c7 --- /dev/null +++ b/src/processing/components/JobsModule.vue @@ -0,0 +1,715 @@ + + + + + diff --git a/src/processing/components/TaskForm.vue b/src/processing/components/TaskForm.vue new file mode 100644 index 000000000..79778e6aa --- /dev/null +++ b/src/processing/components/TaskForm.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/src/processing/components/TaskPicker.vue b/src/processing/components/TaskPicker.vue new file mode 100644 index 000000000..8dcab54ae --- /dev/null +++ b/src/processing/components/TaskPicker.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/processing/components/__tests__/JobsModule.spec.ts b/src/processing/components/__tests__/JobsModule.spec.ts new file mode 100644 index 000000000..c51e7b348 --- /dev/null +++ b/src/processing/components/__tests__/JobsModule.spec.ts @@ -0,0 +1,371 @@ +// --------------------------------------------------------------------------- +// P-06 — race-free provider / task selection in JobsModule. +// +// The two request lifecycles (provider → tasks, task → spec model) are each +// owned by a single Vue `watch`. Every UI event only ASSIGNS the selected id; +// the watcher clears the previous selection's derived state, takes a +// request-local `active` flag (invalidated via `onCleanup`), and commits +// provider / tasks / taskModel / loading / error state ONLY while it is still +// active AND the selected id still matches. So a slower stale request can never +// clobber a newer selection — not through its success path, not its `catch`, +// not even its `finally`. +// +// These tests mount the REAL component (child SFCs auto-stubbed by shallowMount) +// against a fresh Pinia, mock the provider factory to yield a per-id +// controllable fake, and resolve/reject the deferred provider/spec promises OUT +// OF ORDER so the loser always settles after the winner has committed. +// --------------------------------------------------------------------------- + +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'; + +// Per-id fake-provider registry. The mocked engine factory returns the fake +// registered for `config.id`, so `useProcessingJobsStore().getProvider(id)` (which +// lazily dynamic-imports this module) yields a controllable provider per id. +const registry = new Map(); +vi.mock('@/src/processing/engine/createProvider', () => ({ + createProvider: (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'; + +// --------------------------------------------------------------------------- +// Harness helpers +// --------------------------------------------------------------------------- + +type Fn = ReturnType; + +// A fake provider: every contract method is a vi.fn the test drives. Kept as a +// distinct type (methods are `Fn`) so the test can read `.mock`/reassign them, +// while `registry` stores it under the real `ProcessingProvider` contract. +type FakeProvider = { + config: ProcessingProviderConfig; + listTasks: Fn; + getTaskSpec: Fn; + runTask: Fn; + getJob: Fn; + getResults: Fn; + cancelJob: Fn; + stageInput: Fn; + deleteJob: Fn; + listJobHistory: Fn; + getJobHistoryDetail: Fn; +}; + +const cfg = (id: string): ProcessingProviderConfig => ({ + id, + label: id, + baseUrl: `http://${id}/`, + jobsBaseUrl: `http://${id}/jobs`, +}); + +const makeProvider = (id: string): FakeProvider => ({ + config: cfg(id), + listTasks: vi.fn().mockResolvedValue([]), + getTaskSpec: vi.fn(), + runTask: vi.fn().mockResolvedValue({ jobId: `${id}-1` }), + getJob: vi.fn(), + getResults: vi.fn().mockResolvedValue({ + resultState: 'ready', + results: [], + missing: 0, + }), + cancelJob: vi.fn(), + stageInput: vi.fn().mockResolvedValue([]), + deleteJob: vi.fn(), + listJobHistory: vi.fn(), + getJobHistoryDetail: vi.fn(), +}); + +// A minimal valid task-spec envelope (no parameters → nothing to render and +// nothing to gate, so submit is never blocked by a required input). +const envelope = (id: string, title: string): TaskSpecEnvelope => ({ + specVersion: 1, + id, + title, + parameters: [], + outputs: [], +}); + +// Register a fake provider both in the store (config) and the factory registry +// (instance), so the immutable-registration + lazy-getProvider flow resolves it. +const registerFake = ( + store: ReturnType, + provider: FakeProvider +) => { + registry.set(provider.config.id, provider as unknown as ProcessingProvider); + store.registerProviderConfig(provider.config); +}; + +// Internal ` diff --git a/src/processing/components/widgets/BoundsWidget.vue b/src/processing/components/widgets/BoundsWidget.vue new file mode 100644 index 000000000..32c9f84af --- /dev/null +++ b/src/processing/components/widgets/BoundsWidget.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/processing/components/widgets/EnumerationWidget.vue b/src/processing/components/widgets/EnumerationWidget.vue new file mode 100644 index 000000000..329c2e61f --- /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..e00b4ddc1 --- /dev/null +++ b/src/processing/components/widgets/FileWidget.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/processing/components/widgets/NumberWidget.vue b/src/processing/components/widgets/NumberWidget.vue new file mode 100644 index 000000000..0087413f3 --- /dev/null +++ b/src/processing/components/widgets/NumberWidget.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/processing/components/widgets/StringWidget.vue b/src/processing/components/widgets/StringWidget.vue new file mode 100644 index 000000000..bb2abfdc3 --- /dev/null +++ b/src/processing/components/widgets/StringWidget.vue @@ -0,0 +1,23 @@ + + + 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..d643f0377 --- /dev/null +++ b/src/processing/components/widgets/__tests__/NumberWidget.spec.ts @@ -0,0 +1,99 @@ +// --------------------------------------------------------------------------- +// NumberWidget parsing: reject instead of coerce. parseInt-style truncation +// ("1.9" -> 1) silently submitted a value the user never entered; the widget +// must emit null for non-integral / invalid-step / non-numeric input on an +// int param and surface the problem inline. +// --------------------------------------------------------------------------- + +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'); // valid values are 1, 3, 5, ... + 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('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'); + // The parent reflects the emitted null back; the raw text must survive. + 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..da2cbf04e --- /dev/null +++ b/src/processing/config.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +import { isOriginAllowed, resolveOrigin } from '@/src/io/originGate'; +import type { Config } from '@/src/io/import/configJson'; +import type { ProcessingProviderConfig } from '@/src/processing/types'; + +// Deliberately non-strict (zod default): unknown keys strip harmlessly so a +// non-lockstep backend (older, emitting retired keys like `protocol`/`auth`, +// or newer, emitting keys this client doesn't know) still registers. Do not +// add `.strict()` — tolerance is pinned by config.spec.ts and never loosens +// the trust boundary (the origin gate covers every egress field). +const processingProviderConfig = z.object({ + id: z.string(), + label: z.string(), + baseUrl: z.string(), + // Explicit folder-free base for the job-addressed routes (status/results/cancel). + // REQUIRED: the paired backend always advertises it, and the transport has no + // baseUrl fallback (a job's routes are folder-free by construction). A config + // that omits it is rejected here rather than silently mis-routing job calls + // onto the folder-scoped baseUrl. + jobsBaseUrl: z.string(), +}); + +const processingConfigShape = { + processing: z + .object({ + providers: z.array(processingProviderConfig).default([]), + }) + .optional(), +}; + +type ConfigWithProcessing = Config & { + processing?: { + providers?: ProcessingProviderConfig[]; + }; +}; + +export const withProcessingConfig = ( + schema: z.ZodObject +) => schema.extend(processingConfigShape); + +// Provider registration is gated by the shared runtime egress gate +// (`io/originGate`): a provider registers only if its origin is same-origin with +// the deployment. Trust attaches to where the provider points, not to how the +// config arrived — a config can never point egress at a foreign origin. +const isProviderOriginAllowed = (config: ProcessingProviderConfig): boolean => { + // Gate EVERY egress target the provider would reach: the folder-scoped baseUrl + // and the folder-free jobsBaseUrl the job-addressed routes use. Both carry the + // bearer via `$fetch`, so an ungated jobsBaseUrl would be a token-exfiltration + // hole — fail closed on either (the single egress gate). + 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; +}; + +// Select the providers whose egress origin the runtime gate allows. PURE: it +// parses/validates (upstream schema) and returns the allowed configs, touching +// NO store — the caller (the `@/src/processing` composition root) registers +// them. Keeping this store-free is what lets `config.ts` sit in the pure layer. +export const selectAllowedProviders = ( + manifest: Config +): ProcessingProviderConfig[] => { + const providersConfig = (manifest as ConfigWithProcessing).processing + ?.providers; + if (!providersConfig?.length) return []; + // Registration is keyed by id, so ordering is immaterial. + 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..a43ca638a --- /dev/null +++ b/src/processing/engine/__tests__/bounds.spec.ts @@ -0,0 +1,58 @@ +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'; + +// The converter only reads the LPS-axis→column mapping; the direction vectors +// are irrelevant here, so a partial cast keeps the fixture readable. +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)', () => { + // Column-major affine: world = index .* [2,3,4] + [10,20,30]. + // 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)); + // x: 2*{1,5}+10 = {12,20}; y: 3*{2,6}+20 = {26,38}; z: 4*{3,7}+30 = {42,58} + expect(bounds).toEqual([12, 20, 26, 38, 42, 58]); + }); + + it('honors a permuted LPS-axis→column mapping (oriented volume)', () => { + // Sagittal→col2, Coronal→col0, Axial→col1: each axis range lands in a + // different world component, so the world box is still axis-aligned. + const bounds = cropPlanesToWorldBounds( + planes, + mat4.create(), + dirs(2, 0, 1) + ); + // world x ← Coronal[2,6], world y ← Axial[3,7], world z ← Sagittal[1,5] + expect(bounds).toEqual([2, 6, 3, 7, 1, 5]); + }); +}); diff --git a/src/processing/engine/__tests__/createProvider.spec.ts b/src/processing/engine/__tests__/createProvider.spec.ts new file mode 100644 index 000000000..a2db04076 --- /dev/null +++ b/src/processing/engine/__tests__/createProvider.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; + +import { createProvider } from '../createProvider'; +import type { ProcessingProviderConfig } from '@/src/processing/types'; + +// The production provider composes from the one required transport, so EVERY +// operation is present unconditionally — there is no capability-gated optional +// method left (no spread-based presence). +const config: ProcessingProviderConfig = { + id: 'girder-slicer-cli:folder-1', + label: 'Analysis — Folder', + baseUrl: `${window.location.origin}/api/v1/folder/f1/volview_processing`, + jobsBaseUrl: `${window.location.origin}/api/v1/volview_processing`, +}; + +const REQUIRED_METHODS = [ + 'listTasks', + 'getTaskSpec', + 'runTask', + 'getJob', + 'getResults', + 'cancelJob', + 'deleteJob', + 'stageInput', + 'listJobHistory', + 'getJobHistoryDetail', +] as const; + +describe('createProvider — every operation is required', () => { + it('exposes all ten operations as functions', () => { + const provider = createProvider(config); + REQUIRED_METHODS.forEach((method) => { + expect( + typeof (provider as unknown as Record)[method] + ).toBe('function'); + }); + }); + + it('carries the config it was built from', () => { + const provider = createProvider(config); + expect(provider.config).toBe(config); + }); +}); diff --git a/src/processing/engine/__tests__/formModel.spec.ts b/src/processing/engine/__tests__/formModel.spec.ts new file mode 100644 index 000000000..c3781c253 --- /dev/null +++ b/src/processing/engine/__tests__/formModel.spec.ts @@ -0,0 +1,173 @@ +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', +]; + +// --------------------------------------------------------------------------- +// WI1 — the form renders from the validated spec, for every golden fixture. +// --------------------------------------------------------------------------- + +describe('form model renders every golden task-spec fixture', () => { + 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); // int default + expect(values.inputVolume).toBeNull(); // minted from provenance + }); + + 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 NUMERIC enum default with its number type intact', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-bounds-enum.json')) + ); + // Slicer integer-enumeration: options and default stay numbers end-to-end + // (stringifying would submit a type the CLI rejects). + expect(initialFormValues(model).iterations).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// WI2 — fail closed on an unknown / invalid parameter kind. +// --------------------------------------------------------------------------- + +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')) + ); + // Bind the one required (valid) param; the hidden `tint` is optional. + 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']); + }); +}); + +// --------------------------------------------------------------------------- +// Submit gating: required-ness + numeric min/max. +// --------------------------------------------------------------------------- + +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('passes a fully valid value set', () => { + const issues = validateFormValues(model(), { + inputVolume: 'uri:1', + radius: 5, + }); + expect(issues).toHaveLength(0); + }); +}); diff --git a/src/processing/engine/__tests__/jobHistory.spec.ts b/src/processing/engine/__tests__/jobHistory.spec.ts new file mode 100644 index 000000000..41fc6cb5f --- /dev/null +++ b/src/processing/engine/__tests__/jobHistory.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { + filterJobHistory, + jobErrorSummary, + 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('filters by created/started/finished time and job-id/title text', () => { + expect( + filter(jobs, { timeField: 'created', after: '2026-05-15T00:00:00Z' }) + ).toHaveLength(1); + expect( + filter([summary({ startedAt: '2026-06-02T00:00:00Z' }), jobs[1]], { + timeField: 'started', + after: '2026-06-01T00:00:00Z', + }) + ).toHaveLength(1); + expect( + filter([summary({ finishedAt: '2026-06-03T00:00:00Z' }), jobs[1]], { + timeField: 'finished', + before: '2026-06-04T00:00:00Z', + }) + ).toHaveLength(1); + expect(filter(jobs, { text: 'JOB-2' }).map((j) => j.jobId)).toEqual([ + 'job-2', + ]); + expect(filter(jobs, { text: 'rigid' }).map((j) => j.jobId)).toEqual([ + 'job-2', + ]); + }); + + it('filters by output health (only missing outputs)', () => { + expect( + filter(jobs, { outputHealth: 'missing' }).map((j) => j.jobId) + ).toEqual(['job-2']); + // A job with no missing outputs is excluded by the missing-outputs filter. + const healthy = summary({ + jobId: 'healthy', + outputSummary: { recorded: 2, missing: 0 }, + }); + expect(filter([healthy], { outputHealth: 'missing' })).toEqual([]); + }); + + it('includes a matching current-session job after durable history completed', () => { + // `live` and `contexts` are the store's jobKey-keyed maps; a live status + // recovers its providerId from the context at the SAME composite key. + const key = jobKey({ providerId: 'p1', jobId: 'live-job' }); + const rows = 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'], text: 'LIVE-JOB', 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 = 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'], text: 'live-error' } + ); + + expect(rows).toHaveLength(1); + expect(rows[0].providerId).toBe('p1'); + expect(rows[0].errorTail).toBe('Concrete worker failure: invalid mask'); + expect(jobErrorSummary(rows[0])).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..5227682d5 --- /dev/null +++ b/src/processing/engine/__tests__/mintInput.spec.ts @@ -0,0 +1,297 @@ +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'; + +// --------------------------------------------------------------------------- +// DataSource fixtures — the provenance shapes VolView's import pipeline builds. +// A remote DICOM volume is a `collection` of DICOM `chunk`s, each parented to a +// `uri`; a remote single file is a `file` parented to a `uri`. No-provenance +// volumes bottom out in a local `File` / `stateFileLeaf` with no `uri` ancestor. +// --------------------------------------------------------------------------- + +const uriSource = (uri: string): DataSource => ({ + type: 'uri', + uri, + name: uri.split('/').pop() ?? uri, +}); + +// A DICOM chunk whose only meaningful provenance is its parent 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), +}); + +// A DICOM chunk with NO uri ancestor — a locally-dropped file's chunk. Mixed +// into a collection alongside remote chunks, it models the reconstructed +// mixed-provenance volume that must not bind. +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: [], +}); + +// --------------------------------------------------------------------------- +// AC1 — the minted value matches the Appendix B golden fixtures byte-for-byte. +// --------------------------------------------------------------------------- + +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); + }); +}); + +// --------------------------------------------------------------------------- +// Provenance collection is verbatim + in constituent order. +// --------------------------------------------------------------------------- + +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)', () => { + // Some chunks remote, one local: minting only the remote subset would have + // the backend process an incomplete volume — every constituent must + // resolve, or the whole collection is not bindable. + 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' + ); + }); +}); + +// --------------------------------------------------------------------------- +// AC2 — no-provenance volumes are not bindable: fail closed (inline + refuse). +// --------------------------------------------------------------------------- + +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 (WI2/WI3)', () => { + 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'); + // The binder AUTHORS its field on failure (explicit null, never absent) so + // a rebind can never leave a previous image's minted URI in place. + 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'); + // Fail-closed branches still author every owned field (explicit null). + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Integration — through the real spec → form-model path. +// --------------------------------------------------------------------------- + +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..4083f381a --- /dev/null +++ b/src/processing/engine/__tests__/mintLabelmap.spec.ts @@ -0,0 +1,342 @@ +import { describe, it, expect } from 'vitest'; + +import type { DataSource } from '@/src/io/import/dataSource'; +import { + labelmapInputFields, + resolveLabelmapGroup, + bindLabelmapInputs, + mintLabelmapValue, + mintLabelmapReferenceImage, + labelmapStageTargets, + type SegmentGroupView, +} from '../mintLabelmap'; +import { bindImageInputs } from '../mintInput'; +import type { TaskFormModel, FormField } from '../formModel'; + +// --------------------------------------------------------------------------- +// Fixtures — a task model with one labelmap sourceRef, plus a pure segment-group +// view built from a { groupId -> parentImage } map. Building `orderByParent` +// FROM the metadata keeps the view internally consistent, exactly like the +// store invariant (every id in `orderByParent[img]` has `parentImage === img`). +// --------------------------------------------------------------------------- + +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', +}); + +// --------------------------------------------------------------------------- +// labelmapInputFields — binds by the open type tag off the spec, not by Slicer +// semantics. +// --------------------------------------------------------------------------- + +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']); + }); +}); + +// --------------------------------------------------------------------------- +// AC — the fallback chain, all three branches (WI1). +// --------------------------------------------------------------------------- + +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', + }); + }); +}); + +// --------------------------------------------------------------------------- +// AC — the parentImage guard (WI1). +// --------------------------------------------------------------------------- + +describe('resolveLabelmapGroup — parentImage guard', () => { + it('rejects a paint-active group whose parentImage is not the background', () => { + // g1 is paint-active but belongs to a DIFFERENT image; the background's own + // only group (g2) is used instead (guard skips g1, chain continues). + 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', + }); + }); +}); + +// --------------------------------------------------------------------------- +// bindLabelmapInputs — states, issues, and the resolved group. +// --------------------------------------------------------------------------- + +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', () => { + // Mirrors the image binder (mintInput): an optional, unresolved labelmap + // input stays unbound but must NOT gate submit — the task does not need it. + 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); + }); +}); + +// --------------------------------------------------------------------------- +// WI3 — a labelmap painted on a no-provenance background is blocked "for free" +// by the SAME Chunk-8 image-binder gate; no new gate is built here. +// --------------------------------------------------------------------------- + +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: [], + }; + + // The background was dropped locally (no server URIs) → image binder fails + // closed; the labelmap is perfectly resolvable on the same active image. + 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); + + // Submit is gated on the union of both binders' issues: the image + // no-provenance block stands, so the resolvable labelmap cannot clear it. + 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); + }); +}); + +// --------------------------------------------------------------------------- +// mintLabelmapValue / labelmapStageTargets — the value shape + stage targets. +// --------------------------------------------------------------------------- + +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(); + }); +}); + +describe('labelmapStageTargets', () => { + it('lists the (paramId, groupId) pairs to serialize + stage', () => { + const binding = bindLabelmapInputs( + labelmapModel(), + 'bg', + 'g1', + viewOf({ g1: 'bg' }) + ); + expect(labelmapStageTargets(binding)).toEqual([ + { parameterId: 'inputSeg', segmentGroupId: 'g1' }, + ]); + }); + + it('is empty for an unresolved binding (nothing to stage)', () => { + const binding = bindLabelmapInputs(labelmapModel(), 'bg', null, viewOf({})); + expect(labelmapStageTargets(binding)).toEqual([]); + }); +}); diff --git a/src/processing/engine/__tests__/resultToIntent.spec.ts b/src/processing/engine/__tests__/resultToIntent.spec.ts new file mode 100644 index 000000000..c816a950e --- /dev/null +++ b/src/processing/engine/__tests__/resultToIntent.spec.ts @@ -0,0 +1,40 @@ +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)', () => { + // The whole result row is the candidate now, so the parsed intent keeps the + // required `id` (a missing id would itself fail the gate). + 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__/transport.spec.ts b/src/processing/engine/__tests__/transport.spec.ts new file mode 100644 index 000000000..867183826 --- /dev/null +++ b/src/processing/engine/__tests__/transport.spec.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { createEngineTransport } from '../transport'; +import type { ProcessingProviderConfig } from '@/src/processing/types'; +import { setGlobalHeader, deleteGlobalHeader } from '@/src/utils/fetch'; + +// --------------------------------------------------------------------------- +// The one required transport implements the paired backend's FIXED routes and +// canonical parsers directly — there is no descriptor to swap. These tests pin +// every route shape (from the correct base URL) and the real parser round-trips. +// +// The engine routes through `$fetch`, which attaches the global bearer only for +// SAME-ORIGIN requests (P-04), so the config base URLs are built from +// `window.location.origin` — a foreign host would silently drop the bearer. +// --------------------------------------------------------------------------- + +type Call = { url: string; init: RequestInit | undefined }; + +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, +}; + +// Minimal valid wire fixtures per route — the transport calls the REAL canonical +// parsers, so a 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' }, + // The backend always sends jobId on a status; parseJobStatus re-pins it to the + // requested id, but the raw must still carry one to parse. + 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; + +// Route a request URL to its fixture by the most specific path suffix first. +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; // history (baseUrl) + if (/\/jobs\/[^/]+$/.test(path)) return FIXTURES.status; // status / delete + 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); + // Launch-context routes → folder-scoped baseUrl. + expect(urls[0]).toBe(`${BASE}/tasks`); + expect(urls[1]).toBe(`${BASE}/tasks/threshold/spec`); + expect(urls[2]).toBe(`${BASE}/tasks/threshold/run`); + // Job-addressed routes → folder-free jobsBaseUrl. + 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`); + // Durable history is context-scoped (baseUrl), unlike the job routes. + 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('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')); + }); + + 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'); + }); + + // --- parser round-trips for every operation --- + + 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('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`); + }); + + 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..ab92b7af6 --- /dev/null +++ b/src/processing/engine/__tests__/wire.spec.ts @@ -0,0 +1,314 @@ +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', () => { + // `null` is a common JSON serialization of an omitted optional field; it + // must poll, not surface as a born-terminal malformed status. + 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'); + }); + + // Nothing can be tracked without a usable job id, so every unusable form throws. + it.each([ + ['a missing job id', { status: { state: 'success' } }], + ['an empty 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)', () => { + // A descriptor the contract rejects (0-background value, a >255 channel) no + // longer needs a loosened client schema: the canonical `resultIntentSchema` + // union simply demotes this row from the strict `add-segment-group` member to + // the fail-open ordinary branch, so it survives (catchall preserves + // `segments`) as a visible result carrying no state action — one bad + // descriptor never throws away the whole list. + 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)', () => { + // A non-string intent (17) fails every known member and falls to the ordinary + // branch — the old private `z.string().optional()` intent field would have + // rejected the whole envelope and silently loaded nothing. + 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, + }, + ]; + // Null is accepted (not thrown) and normalized to absent so the output + // still matches `ProcessingResult` (mimeType?: string, size?: number). + 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', () => { + // The golden fixture pins the {intents, missing} envelope with PURE-intent + // items (the contract vocabulary floor). The real backend ENRICHES each intent + // with the id/name the JobList reads (exactly `_collectJobResults`' merge), so + // simulate that enrichment before feeding the transport parser. + 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..fd8403bf8 --- /dev/null +++ b/src/processing/engine/bounds.ts @@ -0,0 +1,58 @@ +// --------------------------------------------------------------------------- +// Crop-tool → `bounds` parameter binding (the imaging-native `bounds` field: +// bounds binds from the crop tool). +// +// A `bounds` parameter carries an axis-aligned world-space box in LPS, +// `[xmin, xmax, ymin, ymax, zmin, zmax]` (contract `boundsSchema`). The crop +// tool stores its box per LPS axis in INDEX space (`useCropStore` +// `croppingByImageID`), so binding the crop box to a `bounds` value means +// transforming the box corners through the image's `indexToWorld` and taking +// their axis-aligned world bounding box. Transforming all eight corners keeps +// the result correct for oriented (non-axis-aligned) volumes. +// +// Pure function — no store, no Vue. The caller reads the crop store and the +// image metadata and hands them in. +// --------------------------------------------------------------------------- + +import { vec3 } from 'gl-matrix'; +import type { mat4 } from 'gl-matrix'; +import type { Bounds } from '@/backend-contract'; +import type { LPSDirections } from '@/src/types/lps'; + +// Accept the crop store's deeply-`readonly` planes as-is (the store exposes a +// frozen view); we only read them. Mutable `LPSCroppingPlanes` is assignable. +export type ReadonlyCropPlanes = { + Sagittal: readonly [number, number]; + Coronal: readonly [number, number]; + Axial: readonly [number, number]; +}; + +// `lpsOrientation` maps each LPS axis to its index-space column (the crop store +// keys its ranges by LPS axis; the column places each range into an index-space +// point). +export const cropPlanesToWorldBounds = ( + planes: ReadonlyCropPlanes, + indexToWorld: mat4, + lpsOrientation: LPSDirections +): Bounds => { + const min: [number, number, number] = [Infinity, Infinity, Infinity]; + const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]; + + planes.Sagittal.forEach((s) => { + planes.Coronal.forEach((c) => { + planes.Axial.forEach((a) => { + const index: [number, number, number] = [0, 0, 0]; + index[lpsOrientation.Sagittal] = s; + index[lpsOrientation.Coronal] = c; + index[lpsOrientation.Axial] = a; + const world = vec3.transformMat4(vec3.create(), index, indexToWorld); + for (let k = 0; k < 3; k += 1) { + min[k] = Math.min(min[k], world[k]); + max[k] = Math.max(max[k], world[k]); + } + }); + }); + }); + + return [min[0], max[0], min[1], max[1], min[2], max[2]]; +}; diff --git a/src/processing/engine/createProvider.ts b/src/processing/engine/createProvider.ts new file mode 100644 index 000000000..49b6a2346 --- /dev/null +++ b/src/processing/engine/createProvider.ts @@ -0,0 +1,29 @@ +// --------------------------------------------------------------------------- +// Provider factory — lazy-loaded chunk. +// +// Composes the `ProcessingProvider` the core consumes from the one required +// engine transport. Every live HTTP path (tasks / spec / run / status / results / +// cancel / delete / stage / history) is routed by the transport over the +// bearer-aware `$fetch`; every operation is present unconditionally. +// +// The providers store dynamic-import()s this module so the engine stays out of +// the boot bundle until a provider is actually instantiated. +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import type { + ProcessingProvider, + ProcessingProviderConfig, +} from '@/src/processing/types'; +import { createEngineTransport } from './transport'; + +export const createProvider = ( + config: ProcessingProviderConfig +): ProcessingProvider => { + const transport = createEngineTransport(config); + // The transport already implements every ProcessingProvider method with the + // identical names and signatures, so the provider is exactly that surface plus + // `config` — no per-method forwarding to keep in sync with the transport. + return { config, ...transport }; +}; diff --git a/src/processing/engine/formModel.ts b/src/processing/engine/formModel.ts new file mode 100644 index 000000000..8179345d5 --- /dev/null +++ b/src/processing/engine/formModel.ts @@ -0,0 +1,201 @@ +// --------------------------------------------------------------------------- +// Spec-driven form model — the task description. +// +// Turns a validated task-spec envelope into the render model for the parameter +// form. Each parameter is validated INDIVIDUALLY against the contract's +// `taskParameterSchema`: +// * a param that validates becomes a renderable `field` (a `VolViewTask +// Parameter` — the engine renders straight from the spec type); +// * a param that does NOT validate (unknown `kind`, or a self-inconsistent +// constraint set) is HIDDEN and, if it was `required`, BLOCKS submit with +// an inline reason. This is the fail-closed rule: the engine never silently +// renders a param it cannot type, and never submits omitting a required one. +// +// Value handling is spec-driven too: typed defaults seed the initial values, +// and submit is gated on required-ness plus numeric min/max. `sourceRef` values +// are minted from provenance; `bounds` values are bound from the +// crop tool by the caller. This module stays pure (no store, no Vue). +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import { taskParameterSchema } from '@/backend-contract'; +import type { VolViewTaskParameter } from '@/backend-contract'; +import type { ProcessingValue } from '@/src/processing/types'; +import type { TaskSpecEnvelope } from './taskSpec'; + +// A renderable field is exactly a validated spec parameter. +export type FormField = VolViewTaskParameter; + +// A parameter the engine refused to render (unknown kind / invalid constraints). +export type HiddenField = { + id: string; + required: boolean; + reason: string; +}; + +export type TaskFormModel = { + id: string; + title: string; + description?: string; + // Renderable params, spec `order` first (stable for equal/absent order). + fields: FormField[]; + // Params hidden by the fail-closed rule. + hidden: HiddenField[]; +}; + +export type FormValidationIssue = { + parameter: string; + message: string; +}; + +const asRecord = (raw: unknown): Record => + raw !== null && typeof raw === 'object' + ? (raw as Record) + : {}; + +const firstIssue = (error: { issues: { message: string }[] }): string => + error.issues[0]?.message ?? 'unsupported parameter'; + +export const fieldLabel = (f: FormField): string => f.title ?? f.id; + +const isEmpty = (v: ProcessingValue | undefined): boolean => + v === null || + v === undefined || + (typeof v === 'string' && v.length === 0) || + (Array.isArray(v) && v.length === 0); + +// Stable order: params with an explicit `order` sort ascending; params without +// keep their spec position (relative to each other and after ordered ones). +const byOrder = ( + a: FormField, + b: FormField, + ia: number, + ib: number +): number => { + const oa = a.order ?? Number.POSITIVE_INFINITY; + const ob = b.order ?? Number.POSITIVE_INFINITY; + return oa === ob ? ia - ib : oa - ob; +}; + +// --------------------------------------------------------------------------- +// Model construction +// --------------------------------------------------------------------------- + +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; + } + // Fail closed: keep enough of the raw param to explain the omission and to + // decide whether it blocks submit, but do NOT render it. + const rec = asRecord(raw); + hidden.push({ + id: typeof rec.id === 'string' ? rec.id : '(unnamed)', + required: rec.required === true, + reason: firstIssue(parsed.error), + }); + }); + + const sorted = fields + .map((f, i) => [f, i] as const) + .sort(([a, ia], [b, ib]) => byOrder(a, b, ia, ib)) + .map(([f]) => f); + + return { + id: env.id, + title: env.title, + description: env.description, + fields: sorted, + hidden, + }; +}; + +// Hidden params that were required — the ones that must block submit. +export const blockingHiddenFields = (model: TaskFormModel): HiddenField[] => + model.hidden.filter((h) => h.required); + +// --------------------------------------------------------------------------- +// Values +// --------------------------------------------------------------------------- + +// Seed initial values from typed spec defaults. `sourceRef` is left unbound +// (its value is minted from provenance); `bounds` is left unbound +// unless the spec carries a default (the caller binds it from the crop tool). +export const initialFormValues = ( + model: TaskFormModel +): Record => { + const values: Record = {}; + model.fields.forEach((f) => { + switch (f.kind) { + // Typed default seeds the value, or `null` when the spec omits one. + case 'int': + case 'float': + 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; rendered unbound here. + values[f.id] = null; + break; + // No default: the switch is exhaustive over the contract's known kinds. + } + }); + return values; +}; + +// Gate submit. Returns the inline reasons a submit is refused; an empty array +// means the form is submittable. +export const validateFormValues = ( + model: TaskFormModel, + values: Record +): FormValidationIssue[] => { + const issues: FormValidationIssue[] = []; + + // Fail closed on any required param the engine had to hide. + 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]; + if (f.required && isEmpty(v)) { + issues.push({ + parameter: f.id, + message: `${fieldLabel(f)} is required`, + }); + return; + } + if ((f.kind === 'int' || f.kind === 'float') && typeof v === '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}`, + }); + } + } + }); + + return issues; +}; diff --git a/src/processing/engine/index.ts b/src/processing/engine/index.ts new file mode 100644 index 000000000..549d44c9d --- /dev/null +++ b/src/processing/engine/index.ts @@ -0,0 +1,16 @@ +// --------------------------------------------------------------------------- +// Processing engine. +// +// ONE engine, ZERO per-backend client code. It renders the parameter form from +// a server-emitted, zod-validated task spec and routes all HTTP through the +// bearer-aware `$fetch` over the paired backend's one fixed set of routes. The +// provider factory composes a provider from these pieces; core VolView consumes +// the provider contract only. +// --------------------------------------------------------------------------- + +export * from './transport'; +export * from './taskSpec'; +export * from './formModel'; +export * from './mintInput'; +export * from './bounds'; +export * from './resultToIntent'; diff --git a/src/processing/engine/jobHistory.ts b/src/processing/engine/jobHistory.ts new file mode 100644 index 000000000..f3901c0ff --- /dev/null +++ b/src/processing/engine/jobHistory.ts @@ -0,0 +1,146 @@ +import type { JobState, JobHistorySummary } from '@/backend-contract'; +import type { + ProcessingJobStatus, + SubmittedJobContext, +} from '@/src/processing/types'; +import { jobKey, missingJobErrorDetails } from '@/src/processing/types'; + +export type JobHistoryOutputHealth = 'missing'; + +// A durable history summary carrying the provider that produced it, so a row can +// be keyed by (providerId, jobId) — two folder-scoped providers may share a raw +// jobId. +export type TrackedJobHistorySummary = JobHistorySummary & { + providerId: string; +}; + +export type JobHistoryFilters = { + statuses?: JobState[]; + task?: string; + timeField?: 'created' | 'started' | 'finished'; + after?: string; + before?: string; + text?: string; + outputHealth?: JobHistoryOutputHealth; +}; + +export type JobHistoryDisplayRow = JobHistorySummary & { + providerId: string; + errorTail?: string; +}; + +export const jobHistoryFiltersBlocked = ( + hasFilters: boolean, + historyComplete: boolean +) => hasFilters && !historyComplete; + +const instant = (value: string | undefined) => { + const parsed = value ? Date.parse(value) : Number.NaN; + return Number.isNaN(parsed) ? undefined : parsed; +}; + +// The only output-health predicate: outputs the backend recorded but could not +// resolve (deleted / unreadable files). +const hasMissingOutputs = (job: JobHistorySummary) => + (job.outputSummary?.missing ?? 0) > 0; + +export const filterJobHistory = ( + jobs: T[], + filters: JobHistoryFilters +): T[] => { + const task = filters.task?.trim().toLocaleLowerCase(); + const text = filters.text?.trim().toLocaleLowerCase(); + const after = instant(filters.after); + const before = instant(filters.before); + 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; + } + const timeValue = + filters.timeField === 'started' + ? job.startedAt + : filters.timeField === 'finished' + ? job.finishedAt + : job.createdAt; + const time = instant(timeValue); + if (after != null && (time == null || time < after)) return false; + if (before != null && (time == null || time > before)) return false; + if ( + text && + !job.jobId.toLocaleLowerCase().includes(text) && + !job.taskTitle.toLocaleLowerCase().includes(text) + ) { + return false; + } + if (filters.outputHealth === 'missing' && !hasMissingOutputs(job)) { + return false; + } + return true; + }); +}; + +// Merge durable history rows with live in-session statuses into display rows, +// each keyed by (providerId, jobId). `live` and `contexts` are the store's own +// jobKey-keyed maps, so two providers' identically-numbered jobs never collide. +// A live status carries no providerId of its own; it recovers one from the +// SubmittedJobContext at the SAME composite key. +export const selectJobHistoryRows = ( + durable: TrackedJobHistorySummary[], + live: ReadonlyMap, + contexts: ReadonlyMap, + filters: JobHistoryFilters +): JobHistoryDisplayRow[] => { + const byKey = new Map( + durable.map((job) => [ + jobKey({ providerId: job.providerId, jobId: job.jobId }), + job, + ]) + ); + live.forEach((status, key) => { + // The live overlay written onto both an existing durable row and a + // context-recovered new row. + 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 filterJobHistory(Array.from(byKey.values()), filters); +}; + +export const jobErrorSummary = ( + job: Pick, + detailLog?: string +): string => { + const normalized = + detailLog?.trim().replace(/\s+/g, ' ') || + job.errorTail?.trim().replace(/\s+/g, ' ') || + missingJobErrorDetails(job.jobId); + return normalized.length > 240 + ? `${normalized.slice(0, 237).trimEnd()}...` + : normalized; +}; diff --git a/src/processing/engine/mintInput.ts b/src/processing/engine/mintInput.ts new file mode 100644 index 000000000..e44aee313 --- /dev/null +++ b/src/processing/engine/mintInput.ts @@ -0,0 +1,228 @@ +// --------------------------------------------------------------------------- +// Input minting, CLIENT half. +// +// The client authors the bound input value from the volume's OWN DataSource +// provenance. A volume loaded from the server carries its verbatim launch- +// manifest URIs up its provenance chain; those URIs — never a backend-advertised +// source, never a re-parsed string — are what we push back at submit: the +// client pushes back the grouped refs it already holds; the backend is a pure +// courier. A volume with NO URI provenance (local file drop, archive member, +// restored state file) bottoms out in a File/stateID, has no URIs, and is +// therefore NOT bindable: we fail closed (inline message + refuse submit) rather +// than silently uploading it. +// +// * `type` is a SEMANTIC tag (open vocab) — this binder mints `image` only. +// * `format` is an ADVISORY byte-format hint derived from provenance; it is +// never load-bearing (the CLI re-sorts DICOM by metadata regardless), so we +// do not sink effort into guaranteeing it. +// +// This module is PURE (no store, no Vue): the caller resolves the active +// volume's DataSource and hands it in. +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +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'; + +// --------------------------------------------------------------------------- +// Provenance → URIs (verbatim) +// --------------------------------------------------------------------------- + +// Collect a volume's verbatim provenance URIs in constituent (slice) order. A +// DICOM volume is a `collection` of per-file sources, each of which walks up its +// parent chain to a `uri`; a single remote file walks straight up to its `uri`. +// A branch with no `uri` ancestor (local File / archive / state file) +// contributes nothing, so an EMPTY result means "no URI provenance". For a +// collection, EVERY constituent must resolve: a mixed-provenance volume (some +// remote chunks, some local/archive/state chunks) yields [] rather than a +// PARTIAL uri set the backend would process as an incomplete volume. The URI is +// round-tripped byte-for-byte — the client never parses or constructs it. +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); +}; + +// --------------------------------------------------------------------------- +// Advisory format hint +// --------------------------------------------------------------------------- + +// The trailing filename extension, lower-cased and without the dot, or +// undefined when there is none. Compound extensions collapse to the last +// segment (`scan.nrrd` → `nrrd`); that is fine — `format` is advisory. +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; +}; + +// Advisory only (format is advisory): a DICOM volume (a collection of +// DICOM chunks) → `dicom-series`; a single file → its trailing extension. +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]) + ); +}; + +// --------------------------------------------------------------------------- +// Mint the input value +// --------------------------------------------------------------------------- + +// Mint `{ type, format?, uris }` from a volume's provenance, or `null` when the +// volume has no URI provenance and is therefore not bindable (fail closed). +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 }; +}; + +// --------------------------------------------------------------------------- +// Auto-bind the background image input to the active dataset +// --------------------------------------------------------------------------- + +// Per-`sourceRef`-widget display state. Only `bound` is a success; the rest are +// fail-closed states the widget surfaces inline and the submit gate refuses on. +// Shared by the `image` binder (this module) and the `labelmap` binder +// (`mintLabelmap.ts`); `no-segment-group` is the labelmap-only fail-closed leaf. +export type SourceRefBindingState = + | 'bound' + | 'unbound' // no active dataset yet + | 'no-provenance' // active volume has no server URIs → not bindable + | 'no-segment-group' // labelmap input with no bindable segment group + | 'ambiguous'; // more than one image/labelmap input → no v1 picker + +type ImageSourceRefField = Extract; + +// The renderable `sourceRef` params that accept an `image` input. This binder +// handles `image` only; a `labelmap`-only input is the labelmap binder's +// concern and is left alone. +export const imageInputFields = (model: TaskFormModel): ImageSourceRefField[] => + model.fields.filter( + (f): f is ImageSourceRefField => + f.kind === 'sourceRef' && f.accepts.includes(TYPE_TAG_IMAGE) + ); + +export type ImageBindingResult = { + // Minted values to merge into the form values (the bound image input). + values: Record; + // Per-param widget state, keyed by param id. + states: Record; + // Submit-blocking issues (fail closed). Owns the image params' gating fully, + // so the caller suppresses any generic duplicate for these param ids. + issues: FormValidationIssue[]; +}; + +const EMPTY_BINDING: ImageBindingResult = { + values: {}, + states: {}, + issues: [], +}; + +// Auto-bind the sole `image` sourceRef param to the active dataset. No +// picker in v1: zero image params is a no-op; more than one fails closed. The +// bound value is minted from the active volume's own provenance; a volume with +// no URI provenance fails closed with an inline reason. This function owns +// the image params' submit gating entirely (required-ness included). +export const bindImageInputs = ( + model: TaskFormModel, + activeDataSource: DataSource | undefined +): ImageBindingResult => { + const fields = imageInputFields(model); + + // Nothing to bind — other params gate themselves. + if (fields.length === 0) return EMPTY_BINDING; + + // The binder AUTHORS every field it owns on every rebind — a fail-closed + // branch writes an explicit `null`, never `{}`. Merging `{}` would leave the + // PREVIOUS image's minted URI in the form values, so switching to an + // unbindable image could submit the old image (worst for an OPTIONAL input, + // where no issue blocks the submit). + const nullValues = () => + Object.fromEntries(fields.map((f) => [f.id, null as ProcessingValue])); + + // Fail closed: v1 binds exactly one image input (the active dataset). More + // than one needs a picker we do not ship — refuse rather than guess. + if (fields.length > 1) { + const states = Object.fromEntries( + fields.map((f) => [f.id, 'ambiguous' as const]) + ); + return { + values: nullValues(), + states, + issues: [ + { + parameter: fields[0].id, + message: + 'This task needs more than one image input, which this version cannot bind automatically.', + }, + ], + }; + } + + const [field] = fields; + + // No active dataset: unbound (explicitly null) and defer to the required + // check. + if (!activeDataSource) { + return { + values: nullValues(), + states: { [field.id]: 'unbound' }, + issues: field.required + ? [{ parameter: field.id, message: `${fieldLabel(field)} is required` }] + : [], + }; + } + + const value = mintInputValue(activeDataSource, TYPE_TAG_IMAGE); + + // Fail closed: the active volume has no server URIs, so it is not bindable — + // regardless of required-ness (the user has a volume selected; it simply + // cannot be used as input). + if (!value) { + return { + values: nullValues(), + states: { [field.id]: 'no-provenance' }, + issues: [ + { + parameter: field.id, + message: + 'The active volume was not loaded from the server, so it cannot be used as an input.', + }, + ], + }; + } + + return { + values: { [field.id]: value }, + states: { [field.id]: 'bound' }, + issues: [], + }; +}; diff --git a/src/processing/engine/mintLabelmap.ts b/src/processing/engine/mintLabelmap.ts new file mode 100644 index 000000000..8b319a605 --- /dev/null +++ b/src/processing/engine/mintLabelmap.ts @@ -0,0 +1,232 @@ +// --------------------------------------------------------------------------- +// Labelmap input binding, CLIENT half. +// +// The one input the client authors itself. A `labelmap` sourceRef param +// (`accepts: ["labelmap"]`, authored by the backend's Slicer-XML→spec +// translation — never inferred here) auto-binds to a painted SEGMENT GROUP. +// Unlike the background image, a segment group has NO server-URI provenance: +// it earns provenance at Run through a staging upload (serialize → POST → +// backend-minted `{uris}`), so this module does NOT mint from provenance. It +// only RESOLVES which segment group backs the input (the deterministic fallback +// chain + guard) and reports the fail-closed states; the impure serialize + +// stage + `{type:"labelmap", uris}` mint happens at submit (JobsModule). +// +// Fallback chain, deterministic, no picker: +// (1) the paint-active group, IF its `parentImage` is the bound background; +// (2) else the bound background's ONLY segment group; +// (3) else FAIL CLOSED — "paint or select a segment group first". +// The `parentImage` guard applies to whichever group the chain selects: it must +// belong to the bound background (the active dataset). Multiple groups with none +// paint-active on this background stays fail-closed (the v2 picker is deferred). +// +// This module is PURE (no store, no Vue): the caller passes a plain read-only +// view of the segment-group store so the chain + guard stay unit-testable. +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import type { InputValue } from '@/backend-contract'; +import { TYPE_TAG_LABELMAP } from '@/backend-contract'; +import type { DataSource } from '@/src/io/import/dataSource'; +import type { + FormField, + FormValidationIssue, + TaskFormModel, +} from './formModel'; +import type { SourceRefBindingState } from './mintInput'; +import { mintInputValue } from './mintInput'; + +// --------------------------------------------------------------------------- +// Fields +// --------------------------------------------------------------------------- + +type LabelmapSourceRefField = Extract; + +// The renderable `sourceRef` params that accept a `labelmap` input. The client +// binds purely by the open type tag off the zod-validated spec — +// it hardcodes no Slicer `` knowledge. +export const labelmapInputFields = ( + model: TaskFormModel +): LabelmapSourceRefField[] => + model.fields.filter( + (f): f is LabelmapSourceRefField => + f.kind === 'sourceRef' && f.accepts.includes(TYPE_TAG_LABELMAP) + ); + +// --------------------------------------------------------------------------- +// Segment-group resolution (the fallback chain + guard) +// --------------------------------------------------------------------------- + +// The minimal read-only view of the segment-group store the resolver needs. +// Passed in (rather than reaching into Pinia) so the chain + guard are pure and +// unit-testable. Field names mirror the store: `orderByParent[imageId]` is the +// group ids for that parent image; `metadataByID[groupId].parentImage` is the +// guard field. +export type SegmentGroupView = { + orderByParent: Record; + metadataByID: Record; +}; + +// Resolve the group-to-image relationship already carried by VolView and mint +// that image's ordinary opaque-provenance InputValue. This remains provider +// neutral: callers supply the DataSource lookup, and URI strings are copied +// byte-for-byte without interpretation. +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; +}; + +// Which segment group backs the labelmap input, or `unresolved` (fail closed). +export type LabelmapResolution = + | { kind: 'resolved'; groupId: string } + | { kind: 'unresolved' }; + +// Resolve the fallback chain against the bound background. The `parentImage` +// guard is applied to EVERY branch's candidate: a group whose parentImage is +// not the background is never usable — the chain continues, then fails closed. +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; + + // Branch 1: the paint-active group — usable only if the guard passes. + if (activeSegmentGroupId && belongsToBackground(activeSegmentGroupId)) { + return { kind: 'resolved', groupId: activeSegmentGroupId }; + } + + // Branch 2: the background's ONLY segment group. More than one (with none + // paint-active on this background) stays fail-closed — no v1 picker. + const groups = view.orderByParent[backgroundImageId] ?? []; + if (groups.length === 1 && belongsToBackground(groups[0])) { + return { kind: 'resolved', groupId: groups[0] }; + } + + // Branch 3: fail closed. + return { kind: 'unresolved' }; +}; + +// --------------------------------------------------------------------------- +// Bind the labelmap input +// --------------------------------------------------------------------------- + +export type LabelmapBindingResult = { + // The resolved segment-group id to serialize + stage at Run, keyed by param + // id. Empty when unresolved (fail closed) — there is nothing to stage. + groups: Record; + // Per-param widget state, keyed by param id. + states: Record; + // Submit-blocking issues (fail closed). Owns the labelmap params' gating + // fully, so the caller suppresses any generic duplicate for these param ids. + issues: FormValidationIssue[]; +}; + +const EMPTY_BINDING: LabelmapBindingResult = { + groups: {}, + states: {}, + issues: [], +}; + +// Bind the sole `labelmap` sourceRef param to a resolved segment group. No +// picker in v1: zero labelmap params is a no-op; more than one fails closed +// (the contract's v1 multi-input shape is exactly one background + one +// labelmap). This function owns the labelmap params' submit gating entirely. +export const bindLabelmapInputs = ( + model: TaskFormModel, + backgroundImageId: string | undefined, + activeSegmentGroupId: string | null | undefined, + view: SegmentGroupView +): LabelmapBindingResult => { + const fields = labelmapInputFields(model); + + // Nothing to bind — other params gate themselves. + if (fields.length === 0) return EMPTY_BINDING; + + // Fail closed: v1 binds exactly one labelmap input. More than one needs a + // picker we do not ship — refuse rather than guess. + if (fields.length > 1) { + return { + groups: {}, + states: Object.fromEntries( + fields.map((f) => [f.id, 'ambiguous' as const]) + ), + issues: [ + { + parameter: fields[0].id, + message: + 'This task needs more than one segment group input, which this version cannot bind automatically.', + }, + ], + }; + } + + const [field] = fields; + const resolution = resolveLabelmapGroup( + backgroundImageId, + activeSegmentGroupId, + view + ); + + // No bindable segment group for the bound background. Only BLOCK submit when + // the input is required — an optional labelmap simply stays unbound, matching + // the image binder (mintInput.ts) which also gates its unbound issue on + // `field.required`. + if (resolution.kind === 'unresolved') { + return { + groups: {}, + states: { [field.id]: 'no-segment-group' }, + issues: field.required + ? [ + { + parameter: field.id, + message: 'Paint or select a segment group first.', + }, + ] + : [], + }; + } + + return { + groups: { [field.id]: resolution.groupId }, + states: { [field.id]: 'bound' }, + issues: [], + }; +}; + +// --------------------------------------------------------------------------- +// Mint the labelmap value from the staging response +// --------------------------------------------------------------------------- + +// The bound labelmap value is `{ type: "labelmap", uris }` — the uris come from +// the backend STAGING RESPONSE (backend-minted), never from DataSource +// provenance. `format` is intentionally omitted (advisory only): the +// staged file already carries its `.seg.nrrd` extension in the minted URI. +export const mintLabelmapValue = (uris: string[]): InputValue => ({ + type: TYPE_TAG_LABELMAP, + uris, +}); + +// Narrow a form value to the labelmap `sourceRef` params so the caller can keep +// the async serialize/stage step tightly scoped. +export type LabelmapStageTarget = { + parameterId: string; + segmentGroupId: string; +}; + +// The (paramId, groupId) pairs to serialize + stage at Run, derived from a +// binding result. Empty when nothing resolved. +export const labelmapStageTargets = ( + binding: LabelmapBindingResult +): LabelmapStageTarget[] => + Object.entries(binding.groups).map(([parameterId, segmentGroupId]) => ({ + parameterId, + segmentGroupId, + })); diff --git a/src/processing/engine/resultToIntent.ts b/src/processing/engine/resultToIntent.ts new file mode 100644 index 000000000..a29f00c99 --- /dev/null +++ b/src/processing/engine/resultToIntent.ts @@ -0,0 +1,34 @@ +// --------------------------------------------------------------------------- +// Resolve a wire result to a declarative result intent. +// +// The producer (backend) now emits the neutral `intent` vocabulary natively, so +// the client consumes it directly — there is NO `role` switch here anymore (the +// closed `role` enum is retired: never a closed role enum). +// The single applier maps the resolved intent to store calls. +// +// A missing or unknown intent, or a known-name payload whose shape is invalid, +// carries no VolView state directive. The result remains an ordinary backend +// result record. The gate is the +// STRICT `knownResultIntentSchema` member — a name-known-but-shape-invalid +// result must not be applied as if it were a valid segment group. +// +// Lives in the engine/core home; core imports it (`actions/processResults`). +// --------------------------------------------------------------------------- + +import { + knownResultIntentSchema, + type KnownResultIntent, +} from '@/backend-contract'; +import type { ProcessingResult } from '@/src/processing/types'; + +export const resultToIntent = ( + result: ProcessingResult +): KnownResultIntent | undefined => { + // The whole result row is the candidate: `id`/`name`/`url` are now required + // schema fields (a missing id itself fails the gate), and each strict member + // keeps only what it declares plus passthrough extras (so `segments`/`source` + // survive only on `add-segment-group`) while rejecting an unknown or malformed + // shape. + const known = knownResultIntentSchema.safeParse(result); + return known.success ? known.data : undefined; +}; diff --git a/src/processing/engine/taskSpec.ts b/src/processing/engine/taskSpec.ts new file mode 100644 index 000000000..045d748bd --- /dev/null +++ b/src/processing/engine/taskSpec.ts @@ -0,0 +1,37 @@ +// --------------------------------------------------------------------------- +// Task-spec envelope validation — the task description. +// +// The engine fetches the SERVER-EMITTED, zod-validated VolView task spec and +// renders the parameter form from it — it parses no XML and knows no backend +// format. It consumes the published `backend-contract` package verbatim; it +// never re-derives or re-shapes that schema. +// +// One subtlety drives this module. The whole-spec `taskSpecSchema` is a +// discriminated union over `kind`, so it HARD-rejects a spec that carries an +// unknown parameter kind. But the engine must fail +// closed PER PARAMETER (hide the one bad param, refuse submit only if it was +// required) — not reject the whole form (task-spec.ts, lines 52-57). So the +// engine validates the ENVELOPE here (spec header + outputs, parameters kept +// raw) and validates each parameter individually in `formModel.ts`. +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { taskSpecSchema } from '@/backend-contract'; + +// Reuse the contract's own spec object, swapping only `parameters` for a raw +// pass-through array. Everything else (specVersion / id / title / outputs) is +// validated exactly as the contract defines it — the envelope is a projection +// of the published schema, not a competing copy of it. +export const taskSpecEnvelopeSchema = taskSpecSchema + .omit({ parameters: true }) + .extend({ parameters: z.array(z.unknown()) }); + +export type TaskSpecEnvelope = z.infer; + +// Validate a fetched task spec at the envelope level. Throws (via zod) when the +// spec header itself is malformed — a spec we cannot even frame is not +// renderable. Per-parameter validity is decided later, per param. +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..3f2901af0 --- /dev/null +++ b/src/processing/engine/transport.ts @@ -0,0 +1,233 @@ +// --------------------------------------------------------------------------- +// The one required processing transport. +// +// There is exactly ONE backend interaction model: the paired Girder backend's +// fixed routes, `{ values }` JSON run body, poll lifecycle, and canonical wire +// parsers. This module implements them directly — there is no swappable +// descriptor, no capability-gating, and no unbuilt lifecycle branch. Every +// operation is required and always present. +// +// The launch-context routes (tasks / spec / run / stage / history) are addressed +// off the folder-scoped `baseUrl`; the job-addressed routes (status / results / +// cancel / delete / detail) off the folder-free `jobsBaseUrl`. +// +// All engine HTTP goes through `$fetch` (src/utils/fetch.ts), the origin-aware +// authenticated wrapper that attaches the same-origin bearer. Raw `fetch` would +// bypass that header, so it is never used here. +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { $fetch } from '@/src/utils/fetch'; +import type { JobHistoryDetail, JobHistoryPage } from '@/backend-contract'; +import type { + ProcessingJobRef, + ProcessingJobStatus, + JobResultsBundle, + ProcessingValue, + StageInputRequest, + TaskSummary, + ProcessingProviderConfig, +} from '@/src/processing/types'; +import type { TaskSpecEnvelope } from './taskSpec'; +import { parseTaskSpecEnvelope } from './taskSpec'; +import { + parseJobHistoryPage, + parseJobHistoryDetail, + parseJobRef, + parseJobStatus, + parseResults, + parseStageResponse, +} from './wire'; + +// --------------------------------------------------------------------------- +// Task-summary parsing — advisory pass-through, fail SOFT +// +// Task summaries are advisory display metadata for the picker, not contract +// vocabulary, so this is a LIGHT, lenient guard — not the wire validators. It +// requires only the two fields the picker cannot render without (id/title) and +// keeps every other advisory hint (description/dockerImage/category) verbatim. A +// malformed entry is DROPPED WITH A WARNING, never thrown on: one bad summary +// must never kill the whole picker. A non-array payload degrades to an empty +// list. Deliberately VolView's own light zod schema, NOT one derived from the +// contract wire schemas. +// --------------------------------------------------------------------------- +const taskSummarySchema = z.object({ + id: z.string(), + 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; + }); +}; + +// Route helpers. Every template is a plain join — no route-root string surgery. +const join = (base: string, path: string) => + `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; + +const id = (taskOrJobId: string) => encodeURIComponent(taskOrJobId); + +// --------------------------------------------------------------------------- +// $fetch helpers — bearer-aware, never raw fetch +// --------------------------------------------------------------------------- + +// The HTTP status rides on the thrown error so the job poller can classify it +// (transient vs permanent vs session-expiry vs resource-gone; store/providers.ts +// `classifyError`). A rejected `$fetch` (offline / DNS) carries no status and is +// treated as transient. Functional style: a plain `Error` with a `status` field, +// not an Error subclass. +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; + } +}; + +// --------------------------------------------------------------------------- +// The transport — every operation required and always present +// --------------------------------------------------------------------------- + +export type EngineTransport = { + listTasks: () => Promise; + getTaskSpec: (taskId: string) => Promise; + runTask: ( + taskId: string, + values: Record + ) => Promise; + getJob: (jobId: string) => Promise; + getResults: (jobId: string) => Promise; + // Best-effort cancel. POSTs to the cancel route and validates the projected + // status through the SAME neutral status parser as polling, so a best-effort + // backend that already finished honestly reports its real terminal state + // (never a fabricated `cancelled`). + cancelJob: (jobId: string) => Promise; + deleteJob: (jobId: string) => Promise; + // Stage a parent-bound labelmap as a transient input, returning the + // backend-minted URIs. + stageInput: (request: StageInputRequest) => Promise; + // Durable job re-discovery. Context-scoped (folder-scoped baseUrl). + listJobHistory: (cursor?: string) => Promise; + getJobHistoryDetail: (jobId: string) => Promise; +}; + +export const createEngineTransport = ( + config: ProcessingProviderConfig +): EngineTransport => { + // Launch-context routes ride the folder-scoped baseUrl; job-addressed routes + // ride the folder-free jobsBaseUrl (both required on the config — no fallback). + 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..8889decb5 --- /dev/null +++ b/src/processing/engine/wire.ts @@ -0,0 +1,206 @@ +// --------------------------------------------------------------------------- +// Engine wire validation. +// +// The engine speaks HTTP to an untrusted backend: every job status, job ref, and +// result list arrives as wire JSON. Before this module those payloads were +// `fetchJson` casts straight to typed shapes, so an unknown/missing `state` +// (or a born-terminal ref carrying a garbage status) slipped past the type +// system and made the poller (`store/providers.ts`) loop forever — never +// terminal, never error. This module validates each payload with zod and +// converts an unparseable status into a *terminal error* status so the lifecycle +// stops instead of spinning. +// +// These are the neutral result-format validators the transport calls directly +// — not backend-specific parsing. +// +// Layering note: a result row with an out-of-range segment descriptor is not a +// hard failure — the canonical `resultIntentSchema` union simply demotes it from +// the strict `add-segment-group` member to the fail-open ordinary branch, so it +// stays a visible result that carries no state action, and one bad descriptor +// never rejects the whole list and drops an otherwise-valid base image. +// +// Pure module: zod schemas + parse helpers only, no fetch and no store access. +// +// House rules: functional style; `type`, not `interface`. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { + neutralJobStatusSchema, + jobHistoryPageSchema, + jobHistoryDetailSchema, + jobResultsSchema, + type JobHistoryPage, + type JobHistoryDetail, +} from '@/backend-contract'; +import type { + ProcessingJobRef, + ProcessingJobStatus, + JobResultsBundle, + ProcessingResult, +} from '@/src/processing/types'; + +// --------------------------------------------------------------------------- +// Schemas +// +// These are DERIVED from the contract's canonical objects (the ONE normative +// definition, `backend-contract/processing/wire.ts`) — extended/loosened rather than +// re-declared — so the client's wire layer cannot drift from the contract +// (dedupe). `passthrough()` keeps unknown keys so a valid payload +// round-trips byte-identically — the happy path must not change shape. +// --------------------------------------------------------------------------- + +// Derived from the contract's `neutralJobStatusSchema`: the engine tightens only +// `jobId` (a usable id is mandatory at the trust boundary — nothing can be tracked +// or completion-keyed without it; the contract leaves it a plain producer-side +// string), inheriting `state`/`progress`/`errorTail` unchanged. `satisfies` pins +// the schema to the core type (same idiom as `config.ts`) so the two cannot drift. +const jobStatusSchema = neutralJobStatusSchema.and( + z.object({ jobId: z.string().min(1) }).passthrough() +) satisfies z.ZodType; + +// The result-read envelope is the contract's canonical `jobResultsSchema`, used +// DIRECTLY (no private client copy): every row is a `resultListItem` +// (id/name/url + optional/null metadata) that either matched a known intent or +// fell through to the ordinary branch. A single out-of-range segment descriptor +// no longer needs a loosened client schema — it simply demotes that one row to +// the fail-open ordinary branch (visible, no state action) rather than rejecting +// the whole list. So there is no payload the contract accepts but this parser +// rejects. + +// The job ref envelope: a usable job id is mandatory (nothing can be tracked +// without it); the optional 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: z.string().min(1), + status: z.unknown().optional(), +}); + +// The staging response for client-created labelmap inputs: the +// backend mints `{ uris }` for the bytes the client POSTed. At least one URI is +// mandatory — the client CONSTRUCTS no URI, so an empty/malformed response must +// fail closed rather than mint a labelmap value with no provenance. +const stageResponseSchema = z.object({ + uris: z.array(z.string()).min(1), +}); + +// --------------------------------------------------------------------------- +// Parse helpers +// --------------------------------------------------------------------------- + +const formatIssues = (error: z.ZodError): string => + error.issues + .map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join('; '); + +// Validate a wire job status. A valid payload round-trips unchanged; an invalid +// one becomes a *terminal* error status (keyed to the requested `jobId`) 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); + // Pin the status to the *requested* jobId on both branches. The store keys + // its job map and submitted-context lookup off `status.jobId`, so a provider + // that returned a different id would record the job under the wrong key (UI + // never sees the terminal state, result auto-attach context is lost). The + // error branch already does this; the success branch must match. + if (parsed.success) return { ...parsed.data, jobId }; + return { + jobId, + state: 'error', + resultState: 'unavailable', + errorTail: `Malformed job status from provider: ${formatIssues(parsed.error)}`, + }; +}; + +// Validate a wire job ref. The job id must parse (otherwise nothing can be +// tracked — throw). A present-but-malformed initial status is routed through +// `parseJobStatus`, so a garbage born-terminal status surfaces as a terminal +// error rather than an infinite poll. +export const parseJobRef = (raw: unknown): ProcessingJobRef => { + const parsed = jobRefEnvelopeSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `Malformed job ref from provider: ${formatIssues(parsed.error)}` + ); + } + const { jobId, status } = parsed.data; + // Treat an explicit `status: null` as an absent status (poll it), not a + // malformed born-terminal status: `null` is a common JSON serialization of an + // omitted optional field, and `z.unknown().optional()` preserves it as a + // present value, so a bare `=== undefined` check would flag it as garbage. + return status == null + ? { jobId } + : { jobId, status: parseJobStatus(jobId, status) }; +}; + +// Validate a wire result-read envelope into the neutral `{results, missing}` +// bundle. There is no poll to redirect here, so a +// malformed payload throws; the store's completion path already catches it, +// logs, and notifies subscribers with no results. +export const parseResults = (raw: unknown): JobResultsBundle => { + const parsed = jobResultsSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `Malformed job results from provider: ${formatIssues(parsed.error)}` + ); + } + // Normalize null metadata to absent AFTER canonical parsing — the internal UI + // type (`ProcessingResult`) uses `undefined`, while the wire allows null. + const results: ProcessingResult[] = parsed.data.intents.map((row) => { + const { mimeType, size, ...rest } = row as Record; + return { + ...rest, + ...(mimeType != null ? { mimeType } : {}), + ...(size != null ? { size } : {}), + } as ProcessingResult; + }); + return { + results, + missing: parsed.data.missing, + }; +}; + +// Validate a staging response into the backend-minted URIs. A malformed/empty +// response throws so the caller never mints a `{ type:"labelmap", uris }` value +// with no provenance (the client constructs no URI). +export const parseStageResponse = (raw: unknown): string[] => { + const parsed = stageResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `Malformed staging response from provider: ${formatIssues(parsed.error)}` + ); + } + return parsed.data.uris; +}; + +// Validate a wire job-history page. A +// malformed listing throws so re-discovery fails loud rather than re-attaching +// against a garbage handle; the store treats any listing failure as "no +// re-discovery" and degrades to in-session replay (a re-discovery failure is +// never fatal to the session). +export const parseJobHistoryPage = (raw: unknown): JobHistoryPage => { + const parsed = jobHistoryPageSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `Malformed job history page from provider: ${formatIssues(parsed.error)}` + ); + } + return parsed.data; +}; + +export const parseJobHistoryDetail = (raw: unknown): JobHistoryDetail => { + const parsed = jobHistoryDetailSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `Malformed job history detail from provider: ${formatIssues(parsed.error)}` + ); + } + return parsed.data; +}; diff --git a/src/processing/index.ts b/src/processing/index.ts new file mode 100644 index 000000000..42389b7a7 --- /dev/null +++ b/src/processing/index.ts @@ -0,0 +1,42 @@ +// --------------------------------------------------------------------------- +// Processing feature — PUBLIC SURFACE. +// +// Code OUTSIDE `src/processing/` imports the processing feature ONLY from here +// (enforced by the eslint import-boundary zones in `eslint.config.js`); a deep +// path into the feature is a layering violation. Everything the rest of VolView +// legitimately consumes is re-exported below, and nothing else. +// --------------------------------------------------------------------------- + +import { defineAsyncComponent } from 'vue'; + +import type { Config } from '@/src/io/import/configJson'; +import { selectAllowedProviders } from './config'; +import { useProcessingJobsStore } from './store'; + +// The tracked-job lifecycle store (submit, poll, completion, job history). +export { useProcessingJobsStore } from './store'; +// The live-only "new job result" badge store, read by the segment-group UI. +export { useJobResultReviewStore } from './jobResultReview'; +// The processing config schema extension, applied during config recognition. +export { withProcessingConfig } from './config'; +export type { ProcessingProviderConfig } from './types'; + +// The Jobs side panel, lazily loaded so its chunk (task form + widgets + job +// list) stays out of the boot bundle — ModulePanel mounts it only once a +// provider registers. +export const JobsModule = defineAsyncComponent( + () => import('./components/JobsModule.vue') +); + +// Composition root for provider registration. The pure config layer selects the +// origin-allowed providers; THIS is the single caller that touches the store, +// keeping `config.ts` store-free (the layering inversion). The bootstrap site +// (`io/import/configJson`) calls this and nothing deeper. +export const applyProcessingConfig = async ( + manifest: Config +): Promise => { + const store = useProcessingJobsStore(); + selectAllowedProviders(manifest).forEach((config) => + store.registerProviderConfig(config) + ); +}; diff --git a/src/processing/jobResultReview.ts b/src/processing/jobResultReview.ts new file mode 100644 index 000000000..a65cf6393 --- /dev/null +++ b/src/processing/jobResultReview.ts @@ -0,0 +1,57 @@ +// Live-session review state for auto-shown job results. +// +// A validated result is BORN-PERSISTENT: it is applied as a NORMAL, deletable +// segment group governed by the EXISTING visibility/delete UI — there is NO +// confirm/reject state machine and NO promotion field on the group. Deletability +// alone answers "no undo". The only review cue is a COSMETIC, LIVE-SESSION-ONLY +// badge ("new job result") on the freshly-attached group; this store holds +// exactly that badge set and nothing else. +// +// Deliberately NOT serialized — it never enters the `.volview.zip` (the badge is +// live-only). A page reload starts with an empty set. A re-discovered group is +// badged only when it is FRESHLY re-attached; a session-restored group is skipped +// by the scene-state idempotency guard in the applier, so it is never re-badged. +// +// House rules: functional style; `type`, not `interface`. + +import { defineStore } from 'pinia'; +import { reactive } from 'vue'; + +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; + +export const useJobResultReviewStore = defineStore('jobResultReview', () => { + // Segment-group ids freshly auto-shown from a job result THIS session. A + // reactive Set so the badge in SegmentGroupControls reacts to mark/dismiss. + const newResultGroupIds = reactive(new Set()); + + // Badge a freshly auto-shown result group as new (the provisional review cue). + const markNew = (id: string) => { + newResultGroupIds.add(id); + }; + + // Drop the badge — e.g. the group was deleted, or the user acknowledged the + // cue. Dismissing the badge NEVER deletes the group; the group is a normal + // object and outlives its badge. + const dismiss = (id: string) => { + newResultGroupIds.delete(id); + }; + + const isNew = (id: string) => newResultGroupIds.has(id); + + const clear = () => { + newResultGroupIds.clear(); + }; + + // Layering inversion (dependency points feature → core, never the reverse): + // core `store/segmentGroups` must not import this feature store, so the + // dismiss-on-removal wiring lives HERE. Subscribe to the segment-group store + // and clear a group's badge after ANY removal path runs `removeGroup` — the + // delete UI, the onImageDeleted cascade, and programmatic callers alike. The + // tradeoff is acceptable: a badge only exists once THIS store is + // instantiated, and instantiating it is exactly what installs this hook. + useSegmentGroupStore().$onAction(({ name, args, after }) => { + if (name === 'removeGroup') after(() => dismiss(args[0] as string)); + }); + + return { newResultGroupIds, markNew, dismiss, isNew, clear }; +}); diff --git a/src/processing/store.ts b/src/processing/store.ts new file mode 100644 index 000000000..fb6675388 --- /dev/null +++ b/src/processing/store.ts @@ -0,0 +1,1023 @@ +// Processing providers store. +// +// Holds provider *configs* (registered on app boot from the manifest config +// JSON) and *instances* (created lazily on first `getProvider` call — +// dynamic-imports the generic engine provider chunk). +// +// Also owns the whole tracked-job lifecycle: the submitted-job records, the poll +// loop, the terminal completion firing, and — the in-session durability half — a +// store-level replay so a job that finishes while the Jobs tab is unmounted still +// fires its side effects exactly once when the tab remounts. The record + +// machinery live HERE (not in the Jobs component) so they survive an unmount / +// tab-switch / layout change. Completion replay is in-memory ONLY — it +// deliberately does NOT survive a page reload; after a reload, job-history +// adoption repopulates the Jobs panel + poller. + +import { defineStore } from 'pinia'; +import { computed, reactive, ref } from 'vue'; +import deepEqual from 'fast-deep-equal'; + +import type { JobHistoryDetail, JobHistorySummary } from '@/backend-contract'; +import { 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'; +// A durable job-history row carries the provider that produced it so the client +// can key it by (providerId, jobId) — two folder-scoped providers may mint the +// same raw jobId. Defined once in the engine; reused here. +import type { TrackedJobHistorySummary } from '@/src/processing/engine/jobHistory'; +import { useMessageStore } from '@/src/store/messages'; +import { useDatasetStore } from '@/src/store/datasets'; + +// --------------------------------------------------------------------------- +// Lifecycle tuning constants +// --------------------------------------------------------------------------- + +export const POLL_INTERVAL_MS = 2000; +// Bounded transient-error retries before a poll gives up and fails the job loud +// (item 3 — never an infinite quiet loop). Counts CONSECUTIVE transient errors; +// any successful poll resets it. +export const MAX_POLL_RETRIES = 4; +// Ceiling on the exponential poll backoff so a long-lived transient outage never +// stretches the retry interval without bound. +export const MAX_POLL_BACKOFF_MS = 30000; + +const completionReady = (status: ProcessingJobStatus): boolean => + isTerminalJobState(status.state); + +// --------------------------------------------------------------------------- +// Poll/results error classification +// +// The engine transport throws an `Error` carrying the HTTP `status` (see +// engine/transport.ts). We fail LOUD but discriminate so the poll loop retries +// only what is genuinely transient: +// * 401 / 403 → session/auth expiry — the whole same-origin session is +// dead (item 7). Stop everything, prompt a reload. +// * 404 / 410 → the job or its base image is gone (item 8, server half). +// * other 4xx → permanent — the request is malformed/forbidden; no retry. +// * 5xx / no status → transient — a network blip or server hiccup; retry with +// backoff up to MAX_POLL_RETRIES, then fail the job. +// --------------------------------------------------------------------------- + +type PollErrorKind = + | 'transient' + | 'permanent' + | 'session-expired' + | 'resource-gone'; + +const classifyError = (err: unknown): PollErrorKind => { + const status = (err as { status?: number } | null | undefined)?.status; + if (status === 401 || status === 403) return 'session-expired'; + if (status === 404 || status === 410) return 'resource-gone'; + if (typeof status === 'number' && status >= 400 && status < 500) + return 'permanent'; + // 5xx or no HTTP status (fetch rejected — offline / DNS / CORS) → transient. + return 'transient'; +}; + +const errorMessage = (err: unknown, fallback?: string): string => + err instanceof Error ? err.message : (fallback ?? String(err)); + +// A success whose backend could not resolve every recorded output (deleted / +// unreadable files) is a PARTIAL loss: warn with the count without dropping the +// results that did resolve. One helper for the live-completion and the +// on-demand history-fetch paths so their wording cannot drift. +const warnMissingOutputs = (n: number): void => { + const plural = n === 1 ? '' : 's'; + useMessageStore().addWarning(`${n} output${plural} could not be retrieved`, { + details: `${n} of this job's recorded output${plural} could not be retrieved (deleted or unreadable). The results that resolved are available in the Jobs panel.`, + }); +}; + +// --------------------------------------------------------------------------- +// Completion payload +// +// A single object (functional, extensible) delivered to every completion +// listener. `baseImageMissing` flags item 8: the originating base image was +// removed before the job finished, so results must be surfaced (never silently +// dropped) 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 — Vite emits a separate chunk for the generic engine that's + // only fetched when some surface (typically the Jobs tab) actually + // instantiates a provider, keeping the engine out of the boot bundle. There + // is one generic engine and no per-backend branch: every provider is the + // engine transport reading the neutral-backend default descriptor. + const { createProvider } = + await import('@/src/processing/engine/createProvider'); + return createProvider(config); +}; + +export const useProcessingJobsStore = defineStore('processingJobs', () => { + // Configs are populated on app boot from the manifest config. + const configs = reactive(new Map()); + + // Provider instances are created lazily on first request. + const instances = reactive(new Map()); + const loading = reactive(new Map>()); + + // Job tracking — populated when the user submits a task. EVERY per-job + // collection below is keyed by `jobKey({ providerId, jobId })`, never a raw + // jobId: two folder-scoped providers may mint the same raw jobId, and a raw-id + // key would collide them. + 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); + // Per-provider load errors; the single user-facing string is derived from them + // so the three former hand-derivations can never drift. Empty map → null. + 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 pollTimers = new Map>(); + // Per-job count of consecutive transient poll errors (bounded-retry backoff). + const pollRetries = new Map(); + + // ------------------------------------------------------------------------- + // Per-job lifecycle generation (commit-if-current) + // + // THE one stale-async guard in this store. Every tracked job carries a + // generation stamped from a store-global counter. It advances when the job's + // lifecycle epoch changes — tracked (submit/adoption), polling stopped, + // delete requested — and the entry disappears entirely on delete/clear. A + // continuation captures the generation BEFORE its `await` and commits state + // afterward only if the generation is unchanged, so a job that was deleted + // (or an epoch that ended) while a request was in flight can never be + // resurrected by the late response. The global counter (never per-key + // restarts) means a re-tracked job mints a generation no stale continuation + // can ever hold. + // ------------------------------------------------------------------------- + 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; + + // Set once when a mid-job request meets 401/403: the same-origin session is + // dead. The Jobs component watches this to prompt a reload (item 7). + const sessionExpired = ref(false); + + // Subscribers fired when a job reaches a terminal state with its results. + // Used by the Jobs component to load result files + toast (Phase 5). + const completionListeners = new Set(); + // The last terminal completion for each job, retained so a listener that + // subscribes AFTER the event (the Jobs tab was unmounted when the job + // finished) can be replayed on remount. In-memory only. + const terminalCompletions = new Map(); + // Store-level seen-set: jobIds whose completion has already been delivered to + // a listener. This is what makes replay fire each job's side effects EXACTLY + // ONCE across tab unmount/remount — the component re-subscribes with a FRESH + // callback every mount, so a per-callback set (the old component-local + // `seenToastJobs`) would double-fire. Never persisted (a reload re-discovers). + const firedCompletions = new Set(); + const inFlightCompletions = new Set(); + + // Reactive counter so components can `v-if="providers.providerCount > 0"`. + // Derived from the reactive configs map — never hand-synced. + const providerCount = computed(() => configs.size); + + // Provider registration is IMMUTABLE. A provider id encodes its launch folder, + // so re-registering the same id must never silently swap a live instance's + // config (that recreates the mutable-identity problem in a subtler form): + // * first registration stores the config; + // * a structurally equal duplicate is a no-op; + // * the same id with a different config is a configuration error; + // * a new folder always yields a new id and a fresh instance. + 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` + ); + } + configs.set(config.id, config); + } + + // Single source of truth for per-job (jobId-keyed) state, so deleteJob (one + // id) and clearJobs (all) stay in lockstep — this list silently drifting was + // the item-6 bug. Pollers live in pollTimers/pollRetries, owned by stopPolling. + const perJobCollections: Array<{ + delete(key: string): unknown; + clear(): void; + }> = [ + jobs, + jobHistory, + jobHistoryDetails, + submittedContexts, + jobResults, + jobResultMissing, + jobGenerations, + terminalCompletions, + firedCompletions, + inFlightCompletions, + ]; + + // Drop every tracked job + its pollers and reset the non-per-job history + // scalars. Split out so a provider reset wipes in-flight jobs rather than + // leaking their pollers and stale records. + 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; + }); + // Provider loading must be retryable: a rejected load must not be left in + // `loading` forever (every later getProvider would return the same dead + // promise). Evict it on failure — but only if it is still the current + // in-flight promise — then rethrow so this caller still sees the error. + 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 }); + // Every 2s poll calls this even when nothing changed. Skipping a deep-equal + // write avoids re-running the JobList `jobs` computed (a full history-row + // rebuild + sort) each tick for a job whose status is identical. + 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, + }); + // A job becomes tracked here (submit or history adoption): mint this + // incarnation's lifecycle generation. Never re-mint a live one — that + // would orphan the incarnation's own in-flight continuations. + if (!jobGenerations.has(key)) mintGeneration(key); + submittedContexts.set(key, context); + } + + // ------------------------------------------------------------------------- + // Completion delivery + in-session replay + // ------------------------------------------------------------------------- + + // Deliver a terminal completion to current listeners, retain it for replay, + // and mark it seen. If NO listener is subscribed (Jobs tab unmounted) the + // completion is retained but NOT marked seen, so the next `onJobComplete` + // subscriber replays it — exactly once. + function deliverCompletion(jobRef: TrackedJobRef, completion: JobCompletion) { + // A completion already retained (or already fired) means a second poll + // loop reached the terminal state — e.g. boot adoption overlapping an + // in-flight submit. Delivering it again would double-toast and re-fire + // every listener's side effects. + 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); + // Replay every terminal completion this store has not yet delivered to a + // listener. Marking as we go keeps a subsequent remount from re-firing. + terminalCompletions.forEach((completion, key) => { + if (firedCompletions.has(key)) return; + firedCompletions.add(key); + cb(completion); + }); + return () => completionListeners.delete(cb); + } + + // ------------------------------------------------------------------------- + // Timer + error lifecycle + // ------------------------------------------------------------------------- + + // Keyed by jobKey (the poll collections are all composite-keyed). Callers pass + // jobKey(jobRef); clearJobs/markSessionExpired pass the keys straight from + // pollTimers.keys(). + function stopPolling(key: string) { + const timer = pollTimers.get(key); + if (timer) clearTimeout(timer); + pollTimers.delete(key); + // Per-job transient bookkeeping is dropped on terminal/stop (item 6). + pollRetries.delete(key); + // The polling epoch is over: advance the generation so any in-flight poll + // continuation (an overlapping loop's late getJob) drops instead of + // re-recording state over the terminal status. The job stays tracked. + 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 (boot + // adoption vs in-flight submit) would otherwise leave an untracked timer + // that stopPolling can no longer cancel. + const existing = pollTimers.get(key); + if (existing) clearTimeout(existing); + // The timer callback still polls with the RAW jobId — that is what the + // provider transport addresses. + const timer = setTimeout(() => pollOnce(provider, jobId), 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} result${count === 1 ? '' : 's'} 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}`); + } + } + + // Fail LOUD: synthesize a terminal `error` status and route it through the + // same completion path as any other terminal job so JobList shows it and the + // terminal-message path surfaces it. `detail` prefixes the surfaced tail. + function failJob( + jobRef: TrackedJobRef, + err: unknown, + detail?: string + ): ProcessingJobStatus { + stopPolling(jobKey(jobRef)); + const message = errorMessage(err); + const errorTail = detail ? `${detail}: ${message}` : message; + const status: ProcessingJobStatus = { + jobId: jobRef.jobId, + state: 'error', + resultState: 'unavailable', + errorTail, + }; + recordJob(jobRef.providerId, status); + return status; + } + + // The session is dead (401/403). Stop ALL polling and surface a persistent, + // reload-me message once (item 7). Same-origin means no subtler recovery — + // the whole Girder session, not just this job, is gone. + 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: err instanceof Error ? err : undefined, persist: 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; + } + // transient — bounded retries with exponential backoff before failing loud. + // Only this branch survives the poll; every other kind (and an exhausted + // transient) falls through to the shared fail-and-deliver tail below. + 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; + } + } + // resource-gone / permanent / retries-exhausted are all terminal failures + // that deliver an empty completion — only the surfaced detail differs. + 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), + }); + } + + // ------------------------------------------------------------------------- + // Terminal completion (result-read gating, items 5 + 8) + // ------------------------------------------------------------------------- + + // Item 8 (client half): the originating base image was removed mid-job. Its + // id was recorded at submit but is gone from the dataset store now. + 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); + } + + // ------------------------------------------------------------------------- + // Adopted-job parent reconstruction (reload durability) + // + // An adopted job's context carries no activeDatasetId — the parent binding + // lived in the closed session's memory. The submitted parameters ARE + // persisted server-side though, and the image input value they carry was + // minted VERBATIM from the parent volume's own provenance URIs, so the + // parent can be re-identified among the currently loaded datasets by + // matching those URIs. Resolution is lazy (at completion, when the resumed + // session's datasets are loaded) and fail-closed: no match / ambiguous + // parameters / detail-fetch failure all yield undefined, keeping the + // open-as-dataset fallback. + // ------------------------------------------------------------------------- + + function isImageInputValue( + v: unknown + ): v is { type: string; uris: string[] } { + if (typeof v !== 'object' || v === null) return false; + const candidate = v as { type?: unknown; uris?: unknown }; + return ( + typeof candidate.type === 'string' && + candidate.type !== TYPE_TAG_LABELMAP && + Array.isArray(candidate.uris) && + candidate.uris.length > 0 && + candidate.uris.every((u) => typeof u === 'string') + ); + } + + // Order-insensitive equality: the URIs are round-tripped verbatim, but the + // re-loaded dataset's provenance walk need not enumerate in submit order. + function sameUriSet(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((uri) => b.includes(uri)); + } + + function datasetIdForUris(uris: string[]): string | undefined { + const datasets = useDatasetStore(); + return datasets.idsAsSelections.find((id) => + sameUriSet(collectProvenanceUris(datasets.getDataSource(id)), uris) + ); + } + + async function reconstructAdoptedParentId( + provider: ProcessingProvider, + jobId: string + ): Promise { + let detail: JobHistoryDetail | undefined; + try { + detail = await provider.getJobHistoryDetail(jobId); + } catch { + return undefined; // best-effort — the fallback path still works + } + const imageInputs = Object.values(detail?.parameters ?? {}).filter( + isImageInputValue + ); + // Exactly one persisted image input identifies the parent; anything else + // is ambiguous — never guess a parent to attach results to. + if (imageInputs.length !== 1) return undefined; + return datasetIdForUris(imageInputs[0].uris); + } + + // Shared terminal-completion path. Reached from both the poller and the + // born-terminal fast-path in `submitJob`, so a synchronous job lands results + // identically to a polled one. Assumes `status.state` is already terminal. + 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; + // Commit-if-current: an untracked (already deleted) job has no generation + // and delivers nothing; a tracked one captures its generation here so the + // post-getResults continuation can prove the job wasn't deleted mid-fetch. + const gen = jobGenerations.get(key); + if (gen === undefined) return; + inFlightCompletions.add(key); + let context = submittedContexts.get(key); + try { + // Reload durability: an adopted job finished in THIS session but was + // submitted by a closed one, so its context has no parent id. Rebuild it + // from the persisted input provenance so a labelmap result attaches as a + // segment group instead of opening as a top-level dataset. + if (context && context.activeDatasetId == null) { + const parentId = await reconstructAdoptedParentId(provider, jobId); + if (parentId !== undefined && isCurrent(key, gen)) { + context = { ...context, activeDatasetId: parentId }; + submittedContexts.set(key, context); + } + } + // Item 5: result reads gate on terminal SUCCESS. A non-success terminal + // (error/cancelled) delivers no results — but is never confused with an + // empty success because the status travels with it. + if (status.state !== 'success') { + deliverCompletion(jobRef, { status, results: [], context }); + return; + } + + // Item 5: a results-fetch error is an ERROR, never empty results. On + // failure mark the job errored (loud) and deliver the errored status — the + // old `notify([])` conflated "fetch failed" with "succeeded, no outputs". + let results: ProcessingResult[]; + let unresolvedOutputs: number; + try { + const bundle = await provider.getResults(jobId); + results = bundle.results; + unresolvedOutputs = bundle.missing; + } catch (err) { + if (classifyError(err) === 'session-expired') { + markSessionExpired(err); + return; + } + // Deleted mid-fetch: dropping is correct — recreating the job as an + // error row would resurrect it. + if (!isCurrent(key, gen)) return; + const errored = failJob(jobRef, err, 'failed to fetch job results'); + deliverCompletion(jobRef, { status: errored, results: [], context }); + return; + } + // Deleted mid-fetch: never repopulate jobResults/terminalCompletions for + // a job the user removed while getResults was in flight. + if (!isCurrent(key, gen)) return; + jobResults.set(key, results); + jobResultMissing.set(key, unresolvedOutputs); + + // The backend reports outputs it could not resolve (deleted / unreadable + // files) as a `missing` count on the results envelope. A non-zero + // count on a success is a PARTIAL loss — surface it as a warning ALONGSIDE the + // results that did resolve (they still apply below). Distinct from the + // `baseImageMissing` signal (base image closed mid-job) computed just below — + // a different concept in this same completion path. + if (unresolvedOutputs > 0) warnMissingOutputs(unresolvedOutputs); + + // Item 8: base image removed mid-job → detect + message; results are still + // recorded (JobList shows them) and delivered, never silently dropped. + const missing = baseImageMissing(context); + if (missing) { + const count = results.length; + useMessageStore().addWarning( + 'Base image was closed before the job finished', + { + details: `${count} result${count === 1 ? '' : 's'} 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 now owned by terminalCompletions; a + // retryable 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 }); + // Commit-if-current: capture the generation before the request. deleteJob/ + // clearJobs/stopPolling can all land while getJob is in flight; without + // the re-check, failJob + deliverCompletion would resurrect a deleted job + // with a spurious "Job failed" toast, and the success path would re-record + // a stale status and schedule an untracked poller. + 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; + // Any successful poll resets the transient-error backoff. + pollRetries.delete(key); + recordJob(provider.config.id, status); + if (completionReady(status)) { + stopPolling(key); + await fireCompletion(provider, status); + return; + } + scheduleNextPoll(provider, jobId, POLL_INTERVAL_MS); + } + + // ------------------------------------------------------------------------- + // Submit (item 4 — surface failure, never swallow to console) + // ------------------------------------------------------------------------- + + 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, + }); + + // Async-with-sync-fast-path: a provider may hand back a + // job that is already terminal. Record its real state and route it + // through the same completion path as a polled job, but never register a + // poller. Polling stays the driver only for jobs not yet terminal. + 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; + } + + // Immediate first poll, then self-scheduling with backoff on error. + pollOnce(provider, jobId); + return jobId; + } catch (err) { + // Item 4: submit failure is surfaced in the UI, not swallowed to a + // console.error. Re-thrown so the caller's form resets its submitting + // flag and can distinguish success from failure. + useMessageStore().addError('Failed to submit job', { + error: err instanceof Error ? err : undefined, + }); + throw err; + } + } + + // ------------------------------------------------------------------------- + // Cancel (best-effort) + // ------------------------------------------------------------------------- + + // Request cancellation of a tracked job. One neutral engine call — no Girder + // route/id/JobStatus knowledge here. Deliberately does NOT record a terminal + // status itself: cancel is best-effort (the job may finish before the cancel + // lands), so the EXISTING poller stays the single source of convergence and + // fires completion exactly once on whatever terminal state the backend + // reports. A cancel of an unknown/untracked job is a no-op (fail closed, + // never throws to the UI). + async function cancelJob(jobRef: TrackedJobRef) { + const context = submittedContexts.get(jobKey(jobRef)); + if (!context) return; + try { + const provider = await getProvider(jobRef.providerId); + await provider.cancelJob(jobRef.jobId); + } catch (err) { + // Best-effort: surface the failure but leave the poller running so a job + // that terminates on its own still converges. A mid-cancel 401/403 is the + // same dead-session signal the poller uses. + if (classifyError(err) === 'session-expired') { + markSessionExpired(err); + return; + } + useMessageStore().addError('Failed to cancel job', { + error: err instanceof Error ? err : undefined, + }); + } + } + + async function deleteJob(jobRef: TrackedJobRef) { + const key = jobKey(jobRef); + const context = submittedContexts.get(key); + if (!context) return; + // Tombstone-on-intent: advance the generation SYNCHRONOUSLY so every + // continuation already in flight (a deferred getResults, a late poll) + // drops instead of resurrecting the row mid-delete. Re-minting (not + // removing) keeps the job tracked: if the server delete fails below, the + // row survives and later reads capture the new, still-current generation. + mintGeneration(key); + stopPolling(key); + try { + const provider = await getProvider(jobRef.providerId); + await provider.deleteJob(jobRef.jobId); + // Forget every per-job record in one place (item 6) — the shared list + // keeps this in lockstep with clearJobs. Leaving the completion + // bookkeeping behind would suppress a recycled jobId as already-delivered + // and leak its retained JobCompletion for the session. + perJobCollections.forEach((collection) => collection.delete(key)); + } catch (err) { + useMessageStore().addError('Failed to delete job', { + error: err instanceof Error ? err : undefined, + }); + } + } + + // On-demand results fetch for the explicit historical-apply UI: a job that + // finished while VolView was closed (adopted from history) never ran the + // live-completion path, so its results are not in `jobResults`. Fetch them + // through the SAME `getResults` provider call the live flow uses — one + // result-read path, no second applier. Idempotent: a job already fetched + // (live-completed or previously loaded) short-circuits. + async function loadJobResults(jobRef: TrackedJobRef) { + const key = jobKey(jobRef); + if (jobResults.has(key)) return; + const context = submittedContexts.get(key); + if (!context) return; + // Commit-if-current: a delete that lands while this fetch is in flight + // must not have its results (or an error toast) committed afterward. + const gen = jobGenerations.get(key); + try { + const provider = await getProvider(jobRef.providerId); + const bundle = await provider.getResults(jobRef.jobId); + if (!isCurrent(key, gen)) return; + jobResults.set(key, bundle.results); + jobResultMissing.set(key, bundle.missing); + if (bundle.missing > 0) warnMissingOutputs(bundle.missing); + } catch (err) { + if (classifyError(err) === 'session-expired') { + markSessionExpired(err); + return; + } + if (!isCurrent(key, gen)) return; + useMessageStore().addError('Failed to load job results', { + error: err instanceof Error ? err : undefined, + }); + } + } + + async function loadJobHistoryDetail(jobRef: TrackedJobRef) { + const key = jobKey(jobRef); + if (jobHistoryDetails.has(key)) return; + const context = submittedContexts.get(key); + if (!context) return; + // Commit-if-current (same stale-read rule as loadJobResults). + 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: err instanceof Error ? err : undefined, + }); + } + } + + // ------------------------------------------------------------------------- + // Complete job history is paged from existing backend jobs. Terminal + // summaries are observability rows only; non-terminal summaries 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); + // History adoption attaches the provider that produced the row so the UI + // can key it by (providerId, jobId) too. + jobHistory.set(key, { ...summary, providerId }); + // Already tracked this session (an in-session submit owns it) → do not re-adopt. + if (submittedContexts.has(key) || jobs.has(key)) return; + + recordSubmittedContext({ + jobId: summary.jobId, + taskId: summary.taskId, + providerId, + submittedAt: summary.createdAt, + display: { taskTitle: summary.taskTitle, parameters: [] }, + }); + // Commit-if-current for the live re-fetch below: a clearJobs/deleteJob + // landing while getJob 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; + } + + // Still running from a prior page life: adopt it into the normal poller so + // it finishes THIS session through the ordinary in-session live path. + let status: ProcessingJobStatus; + try { + status = await provider.getJob(summary.jobId); + } catch (err) { + if (classifyError(err) === 'session-expired') markSessionExpired(err); + return; // never fabricate a state for a re-discovered job + } + if (!isCurrent(key, gen)) return; + recordJob(providerId, status); + if (completionReady(status)) { + await fireCompletion(provider, status); + return; + } + // scheduleNextPoll (not pollOnce) avoids an immediate re-fetch — we + // already have a fresh status. + 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, + errorMessage(err, 'Failed to load job history') + ); + return; + } + const cursor = jobHistoryCursors.get(providerId) ?? undefined; + try { + const page = await provider.listJobHistory(cursor); + jobHistoryCursors.set(providerId, page.nextCursor); + jobHistoryErrors.delete(providerId); + await Promise.all( + page.jobs.map((summary) => + adoptDiscoveredJob(provider, providerId, summary) + ) + ); + } catch (err) { + console.error('Job re-discovery failed', err); + jobHistoryErrors.set( + providerId, + errorMessage(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; + } + } + + async function loadAllJobHistory() { + while (!jobHistoryComplete.value && jobHistoryError.value == null) { + await loadMoreJobHistory(); + } + } + + async function retryJobHistory() { + await loadMoreJobHistory(); + if (jobHistoryError.value == null) await loadAllJobHistory(); + } + + async function adoptJobHistory() { + jobHistoryCursors.clear(); + jobHistoryErrors.clear(); + jobHistoryComplete.value = configs.size === 0; + await loadMoreJobHistory(); + } + + return { + configs, + instances, + jobs, + jobHistory, + jobHistoryDetails, + jobHistoryLoading, + jobHistoryComplete, + jobHistoryError, + jobResults, + jobResultMissing, + submittedContexts, + providerCount, + sessionExpired, + + registerProviderConfig, + clearProviders, + clearJobs, + getProvider, + recordJob, + recordSubmittedContext, + submitJob, + cancelJob, + deleteJob, + loadJobResults, + loadJobHistoryDetail, + onJobComplete, + stopPolling, + adoptJobHistory, + loadMoreJobHistory, + loadAllJobHistory, + retryJobHistory, + }; +}); diff --git a/src/processing/types.ts b/src/processing/types.ts new file mode 100644 index 000000000..537d3cb98 --- /dev/null +++ b/src/processing/types.ts @@ -0,0 +1,225 @@ +// --------------------------------------------------------------------------- +// Provider contract — VolView core consumes these types only. +// +// One engine speaks to the paired processing backend through this client +// contract. The provider is composed by `engine/provider.ts` from the one +// required transport (`engine/transport.ts`). +// --------------------------------------------------------------------------- + +// Type-only import (erased at runtime — no import cycle with the engine). +import type { TaskSpecEnvelope } from '@/src/processing/engine/taskSpec'; +// The neutral input value the client mints from provenance at submit. +// `{ type, format?, uris }`. +import type { + InputValue, + StageInputDescriptor, + JobState, + ResultState, + JobHistoryPage, + JobHistoryDetail, + ResultSource, + SegmentDescriptor, +} from '@/backend-contract'; +import { JOB_STATES } from '@/backend-contract'; + +// The contract (`backend-contract/processing/wire.ts`) is the ONE normative definition of +// the shared wire vocabulary. Re-export its job-lifecycle tuple + type here so the +// client binds to that single source of truth instead of redeclaring it. +// `JOB_STATES` is the runtime tuple; `JobState` the neutral union. +export { JOB_STATES }; +export type { JobState }; + +// The one "job has settled" predicate. A job is terminal once it can no longer +// change state — success, error, or cancelled. Mirrors the backend's +// `isTerminalStatus` chokepoint so the client never re-spells the triple. +export const isTerminalJobState = (state: JobState): boolean => + state === 'success' || state === 'error' || state === 'cancelled'; + +// The single "provider failed the job but gave no details" fallback string. +// Shared by the store's terminal-message path and the JobList error summary so +// the two can never drift. +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; + // REQUIRED explicit base for the job-addressed routes (status/results/cancel), + // which are keyed by job id alone and served off a folder-free surface. The + // backend always advertises `/api/v1/volview_processing`; the transport has no + // baseUrl fallback, so a config missing it is a configuration error, not a + // silent mis-route onto the folder-scoped baseUrl. + jobsBaseUrl: string; +}; + +// A client-side job reference. Job identity is always the pair +// `(providerId, jobId)`: two folder-scoped providers may each mint the same raw +// `jobId` (e.g. both return "1"), so a raw id alone can never key a job-owned +// collection. The raw `jobId` is used only when calling that job's own provider. +export type TrackedJobRef = { + providerId: string; + jobId: string; +}; + +// The one stable key helper for every job-owned map/set. JSON-array +// serialization of the two strings is unambiguous regardless of characters +// embedded in either (the provider id itself contains a colon). +export const jobKey = ({ providerId, jobId }: TrackedJobRef): string => + JSON.stringify([providerId, jobId]); + +export type ProcessingProvider = { + config: ProcessingProviderConfig; + + listTasks: () => Promise; + // Server-emitted, zod-validated task description. The engine renders the + // parameter form from this — it parses no XML at runtime. + getTaskSpec: (taskId: string) => Promise; + runTask: ( + taskId: string, + values: Record + ) => Promise; + getJob: (jobId: string) => Promise; + // The result-read envelope (`{intents, missing}`): the resolved results PLUS a + // count of recorded outputs the backend could not + // resolve. The store surfaces a partial-loss warning on a non-zero count while + // still applying the results that resolved. + getResults: (jobId: string) => Promise; + // Best-effort cancel of a tracked job. One neutral engine call: the caller + // holds no Girder route/id/JobStatus knowledge. Returns the job's projected + // status after the attempt, but the store's poller — not this return — is what + // converges the UI on whatever terminal state the backend ultimately reports (a + // job may finish before the cancel lands, so `cancelled` is never fabricated). + cancelJob: (jobId: string) => Promise; + deleteJob: (jobId: string) => Promise; + // Stage a serialized segment group together with its neutral reference-image + // provenance, returning the backend-minted URIs the client round-trips as a + // `{ type: "labelmap", uris }` value. + stageInput: (request: StageInputRequest) => Promise; + // Durable job-history re-discovery. Returns the launch context's jobs as + // neutral summaries (jobId, taskId/taskTitle, createdBy/createdAt, state, + // resultState, outputSummary, and optional startedAt/finishedAt/progress) — + // no route, no JobStatus enum, no file id. The store calls it on load to + // repopulate the Jobs panel + poller after a reload. + listJobHistory: (cursor?: string) => Promise; + getJobHistoryDetail: (jobId: string) => Promise; +}; + +// Advisory display metadata for the task picker (id/title + optional hints). +// The backend emits it; the engine passes it through without a schema. +export type TaskSummary = { + id: string; + title: string; + description?: string; + dockerImage?: string; + category?: string[]; +}; + +export type ProcessingValue = + | string + | number + | boolean + | string[] + | number[] + // Input value minted from the bound volume's own DataSource provenance — + // the value a `sourceRef` param carries. + | InputValue + | null; + +export type ProcessingJobStatus = { + jobId: string; + state: JobState; + resultState: ResultState; + progress?: number; + errorTail?: string; +}; + +export type ProcessingJobRef = { + jobId: string; + /** + * Optional initial status. The async lifecycle has a synchronous fast-path: + * a provider's `runTask` may return a job that is already terminal ("born + * terminal" — e.g. a synchronous `/infer` backend). When the + * status is terminal the store routes it through the same completion path as a + * polled job (auto-apply hook, JobList rendering) but never registers a + * poller. When absent the job is treated as `pending` and polled — polling + * stays the driver for non-terminal jobs, unchanged. + */ + status?: ProcessingJobStatus; +}; + +// The segment descriptor shape is the contract's canonical `SegmentDescriptor` +// (`backend-contract/processing/wire.ts`) — aliased here rather than redeclared so the two +// cannot drift. RGBA 0-255; `value` a label index >= 1. +export type ProcessingSegmentDescriptor = SegmentDescriptor; + +export type ProcessingResult = { + id: string; + name: string; + url: string; + /** + * Provider-supplied result intent — the neutral v1 vocabulary the single + * applier applies (`backend-contract/processing/wire.ts`). Typed + * loosely because it arrives as untrusted wire JSON; `resultToIntent` + * resolves it against the canonical schema. Missing, unknown, or malformed + * values carry no VolView state directive. + */ + intent?: string; + mimeType?: string; + size?: number; + /** + * Provider-supplied segment descriptors. Only meaningful for an + * `add-segment-group` intent. When present, VolView applies these + * names/colors to the created segment group instead of auto-generating. + */ + segments?: ProcessingSegmentDescriptor[]; + /** + * Provenance tag the backend stamps on an `add-segment-group` result + * (`{ jobId, outputId }`). The applier threads it onto the created segment + * group so it round-trips the `.volview.zip` as DISPLAY PROVENANCE only + * (nothing keys dedup or attach semantics off it). Structurally + * the `source?` field on `SegmentGroupMetadata`. + */ + source?: ResultSource; +}; + +// The result-read envelope the engine hands the store (`jobResultsSchema`): +// the parsed results plus a count of declared outputs the backend could not +// publish or resolve. `missing` +// is reported rather than silently dropped so a "success with no outputs" stays +// distinct from "outputs deleted", and the store can surface a partial-loss +// warning alongside the results that did resolve. Distinct from the +// re-association `baseImageMissing` signal — a different concept. +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[]; +}; + +// VolView remembers which dataset / source was active 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..d99bed96b --- /dev/null +++ b/src/store/__tests__/annotationToolImageDelete.spec.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { 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([]); + }); +}); + +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..eb02f87d1 --- /dev/null +++ b/src/store/__tests__/datasetRemoveCascade.spec.ts @@ -0,0 +1,167 @@ +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. +// +// The tell of the bug is the absence of `await nextTick()` in these tests: +// segment-group / annotation cleanup used to run on an async `onImageDeleted` +// watcher (a tick late), and the view / crop / paint holders were never wired +// at all — so a same-tick serialize wrote orphaned ids the manifest normalizer +// then had to strip. This exercises the real `datasetStore.remove` entrypoint. +// --------------------------------------------------------------------------- + +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); + }); +}); diff --git a/src/store/__tests__/datasets-layers.spec.ts b/src/store/__tests__/datasets-layers.spec.ts new file mode 100644 index 000000000..33e39fee1 --- /dev/null +++ b/src/store/__tests__/datasets-layers.spec.ts @@ -0,0 +1,79 @@ +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(), + // Read by the `onImageDeleted` watcher a store instantiates on the + // message-store error path (the failure case surfaces a toast). + imageById: {} as Record, + }, + }) +); + +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(); + }); +}); 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..1f17b7429 --- /dev/null +++ b/src/store/__tests__/remote-save-state.spec.ts @@ -0,0 +1,158 @@ +// One gate for all configured egress: the remote-save target passes the SAME +// runtime origin gate as processing providers. A cross-origin target never +// reaches `saveUrl`, so the surface (gated on `saveUrl !== ''`) and its egress +// both stay inert. + +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 passes the shared origin gate', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked($fetch).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('accepts a same-origin save URL with zero config', () => { + const store = useRemoteSaveStateStore(); + const url = `${window.location.origin}/api/session/save`; + + store.setSaveUrl(url); + + expect(store.saveUrl).toBe(url); + }); + + it('refuses a cross-origin save URL', () => { + const store = useRemoteSaveStateStore(); + + store.setSaveUrl('https://attacker.example/save'); + + expect(store.saveUrl).toBe(''); + expect( + useMessageStore().messages.some( + (message) => message.title === 'Remote save unavailable' + ) + ).toBe(true); + }); + + it('performs no egress to a refused save target', async () => { + const store = useRemoteSaveStateStore(); + + store.setSaveUrl('https://attacker.example/save'); + await store.saveState(); + + expect($fetch).not.toHaveBeenCalled(); + }); +}); + +// On a successful save the backend returns a single +// `resumeUrl`; the client repoints BOTH the tab's `urls=` (a future F5 reloads +// the save) AND `save=` (subsequent saves go item-scoped into the same session +// item) at it, and re-targets its in-memory save URL — no reload, `config=` +// untouched. A response without `resumeUrl` (or an unparseable body) leaves the +// tab as-is. +describe('resume repoint on save', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked($fetch).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('repoints urls= AND save= to the resumeUrl and re-targets the save url (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(); + store.setSaveUrl(`${window.location.origin}/api/session/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('save')).toBe(resumeUrl); + // Subsequent in-session saves now target the same session item. + expect(store.saveUrl).toBe(resumeUrl); + expect( + useMessageStore().messages.some((m) => m.title === 'Save Successful') + ).toBe(true); + }); + + 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); + }); + + it('leaves the tab and save url as-is when the resumeUrl is cross-origin (fail-safe no-op)', async () => { + // A same-origin save succeeds, but the backend hands back a cross-origin + // resumeUrl. The repoint (urls=/save= stamp + in-memory re-target) is gated + // on the SAME origin gate as setSaveUrl, so it must be a fail-safe no-op: + // the ordinary save still succeeds, but nothing is repointed off-origin. + const saveUrl = `${window.location.origin}/api/session/save`; + const resumeUrl = 'https://evil.example.com/api/v1/item/x/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(saveUrl); + + await store.saveState(); + + // (d) address bar never stamped at the cross-origin resumeUrl. + expect(replace).not.toHaveBeenCalled(); + // (c) in-memory save url unchanged — still the original same-origin item, + // not '' and not the cross-origin resumeUrl. + expect(store.saveUrl).toBe(saveUrl); + // (a) the ordinary save still succeeded. + expect( + useMessageStore().messages.some((m) => m.title === 'Save Successful') + ).toBe(true); + // (b) no persistent 'Remote save unavailable' warning surfaced. + expect( + useMessageStore().messages.some( + (m) => m.title === 'Remote save unavailable' + ) + ).toBe(false); + }); +}); diff --git a/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts b/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts new file mode 100644 index 000000000..4c91e0cb9 --- /dev/null +++ b/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts @@ -0,0 +1,222 @@ +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 { 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 composed manifest exactly as the refined backend emits 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 seat = ( + id: string, + name: string, + image: vtkImageData, + segmentMetadata?: Map +) => + useImageCacheStore().addVTKImageData(image, name, { + id, + ...(segmentMetadata ? { segmentMetadata } : {}), + }); + +// 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 { segmentGroupIDMap: idMap } = await store.deserialize( + descriptorlessComposedManifest(), + [], + { + 'ds-ct': 'parent-store', + [leafStateId(3)]: 'artifact-store', + } + ); + 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('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..bc4445f7a --- /dev/null +++ b/src/store/__tests__/segmentGroupRestoreResilience.spec.ts @@ -0,0 +1,337 @@ +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 } 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 — the old code +// awaited `untilLoaded(undefined)`, which has no timeout — 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; +} + +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 store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( + manifestWith([ + group('sg-tumor', { dataSourceId: 3 }), + group('sg-liver', { dataSourceId: 4 }), + ]), + [], + { 'ds-ct': 'store-ct', [leafStateId(4)]: 'store-liver' } + ); + + expect(idMap['sg-tumor']).toBeUndefined(); + // The survivor still attaches. + expect(idMap['sg-liver']).toBeDefined(); + // The drop is reported with a concrete reason, not silent. + 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 store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( + manifestWith([group('sg-tumor', { dataSourceId: 3 }, 'ds-missing')]), + [], + { [leafStateId(3)]: 'store-seg' } + ); + + expect(idMap).toEqual({}); + expect(Object.keys(store.metadataByID)).toEqual([]); + // Reported with the unresolved-parent reason. + 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 store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( + 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(); + // The read failure is reported with the parse/read reason. + 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' + ); + // The notice names the group AND why it was left out. + 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' } + ); + + // Survivor attaches... + expect( + Object.values(store.metadataByID).some((m) => m.name === 'sg-liver') + ).toBe(true); + // ...and the dropped group is not restored. + expect( + Object.values(store.metadataByID).some((m) => m.name === 'sg-tumor') + ).toBe(false); + + // ...and the user is told WHY the tumor group was left out. + 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 (P-08): 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 store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( + 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' }, + ]); + // Before P-08 the failure path leaked the artifact; now it is cleaned up. + 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 store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap } = await store.deserialize( + manifestWith([group('sg-tumor', { dataSourceId: 3 })]), + [], + { 'ds-ct': 'store-ct', [leafStateId(3)]: 'store-tumor' } + ); + + expect(idMap['sg-tumor']).toBeDefined(); + // Exactly one cleanup — the old code had two potential cleanup sites. + 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 store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( + 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([]); + // The shared temp dataset is removed exactly once, after both groups read it. + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith('store-tumor'); + expect(useImageCacheStore().imageById).not.toHaveProperty('store-tumor'); + }); + + it('does not remove any dataset for an archive-backed (path) group', async () => { + seatImage('store-ct', 'CT Chest'); + seatImage('bystander', 'Unrelated'); + ioMocks.readImage.mockResolvedValue(makeImage()); + const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); + + const store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap } = await store.deserialize( + 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'); + }); +}); diff --git a/src/store/datasets-images.ts b/src/store/datasets-images.ts index ce7dc8ff6..99d9de9ab 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; segmentMetadata?: 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, + segmentMetadata: options.segmentMetadata, + }); } function deleteData(id: string) { diff --git a/src/store/datasets-layers.ts b/src/store/datasets-layers.ts index 5e1a35561..88c99fcda 100644 --- a/src/store/datasets-layers.ts +++ b/src/store/datasets-layers.ts @@ -61,12 +61,18 @@ export const useLayersStore = defineStore('layer', () => { const name = imageCacheStore.getImageMetadata(source)?.name ?? NO_NAME; imageCacheStore.addVTKImageData(image, name, { id }); + // Return the built layer id so callers can distinguish success from the + // `undefined` the useErrorMessage wrapper resolves to when _addLayer throws. + return id; } async function addLayer(parent: DataSelection, source: DataSelection) { return useErrorMessage('Failed to build layer', async () => { try { - await _addLayer(parent, source); + // Return the built layer id so the public wrapper resolves to it on + // success (and to `undefined` only when useErrorMessage swallows a + // throw) — callers distinguish a real build from a swallowed failure. + return await _addLayer(parent, source); } catch (error) { // remove failed layer from parent's layer list parentToLayers[parent] = parentToLayers[parent]?.filter( diff --git a/src/store/datasets.ts b/src/store/datasets.ts index 05a257f76..12f1696ee 100644 --- a/src/store/datasets.ts +++ b/src/store/datasets.ts @@ -141,6 +141,15 @@ 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 getDataSource = ( + id: string | null | undefined + ): DataSource | undefined => + id ? loadedData.value.find((d) => d.dataID === id)?.dataSource : undefined; + // --- actions --- // async function serialize(stateFile: Schema.StateFile) { @@ -169,6 +178,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); @@ -195,6 +209,7 @@ export const useDatasetStore = defineStore('dataset', () => { return { idsAsSelections, + getDataSource, addDataSources, serialize, remove, diff --git a/src/store/image-cache.ts b/src/store/image-cache.ts index ed864b5ec..6163fac9e 100644 --- a/src/store/image-cache.ts +++ b/src/store/image-cache.ts @@ -101,9 +101,14 @@ export const useImageCacheStore = defineStore('image-cache', () => { function addVTKImageData( imageData: vtkImageData, name: string, - options: { id?: string } = {} + options: { id?: string; segmentMetadata?: Map } = {} ) { - return addProgressiveImage(new LoadedVtkImage(imageData, name), options); + const image = new LoadedVtkImage(imageData, name); + // Embedded `.seg.nrrd` segment metadata, read back by + // `decodeSegments` — the non-DICOM analogue of `segBuildInfo`. + if (options.segmentMetadata) + image.segmentMetadata = options.segmentMetadata; + return addProgressiveImage(image, options); } function removeImage(id: string) { diff --git a/src/store/remote-save-state.ts b/src/store/remote-save-state.ts index 0e5fbf0a7..a79eb79d1 100644 --- a/src/store/remote-save-state.ts +++ b/src/store/remote-save-state.ts @@ -1,17 +1,51 @@ import { serialize } from '@/src/io/state-file/serialize'; import { useMessageStore } from '@/src/store/messages'; +import { isOriginAllowed } from '@/src/io/originGate'; import { $fetch } from '@/src/utils/fetch'; import { defineStore } from 'pinia'; import { ref } from 'vue'; +// On a successful save the backend returns a single field, `resumeUrl` — the +// session's save/load URL. The client repoints BOTH the tab's `urls=` (so a +// future F5 reloads the just-made save instead of the fresh launch manifest) AND +// its `save=` (so subsequent saves target the SAME session item — item-scoped — +// instead of minting a new session zip each time) at it, via +// `history.replaceState` (no reload). VolView constructs no Girder route and +// never learns the item id; it only knows "point future refreshes and saves at +// `resumeUrl`." A response without `resumeUrl` (or an unparseable body) leaves +// the tab as-is (fail-safe). `config=` is untouched. +const repointToResumeUrl = (resumeUrl: string) => { + const url = new URL(window.location.toString()); + const params = new URLSearchParams(url.search); + params.set('urls', resumeUrl); // future F5 reloads the save + params.set('save', resumeUrl); // future saves go item-scoped into the same item + url.search = `?${params.toString()}`; + window.history.replaceState(null, '', url.toString()); +}; + const useRemoteSaveStateStore = defineStore('remoteSaveState', () => { const saveUrl = ref(''); const isSaving = ref(false); const messageStore = useMessageStore(); + // The remote-save target passes the SAME runtime egress gate as processing + // providers — one gate for all configured egress. A cross-origin target never + // reaches `saveUrl`, so the remote-save surface (gated on `saveUrl !== ''`) + // and its egress both stay inert. Only a same-origin target is accepted. const setSaveUrl = (url: string) => { - saveUrl.value = url; + if (isOriginAllowed(url)) { + saveUrl.value = url; + } else { + saveUrl.value = ''; + messageStore.addWarning('Remote save unavailable', { + details: `The configured save target is not allowed: ${url}`, + persist: true, + }); + console.warn( + `Ignoring remote-save URL because its origin is not allowed: ${url}` + ); + } }; const saveState = async () => { @@ -29,11 +63,35 @@ 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 one. 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' && + isOriginAllowed(body.resumeUrl) + ) { + // Same-origin resumeUrl only (matches the setSaveUrl egress gate): + // stamp urls=/save= for an F5-stable resume AND re-target in-session + // saves at the same item. A cross-origin resumeUrl is a fail-safe + // no-op — the tab is left as-is (no address-bar stamp, saveUrl + // unchanged, no persistent warning); the save still succeeded, but a + // later F5 will not repoint (acceptable under the same-origin policy). + repointToResumeUrl(body.resumeUrl); + setSaveUrl(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..9ab5283b6 100644 --- a/src/store/segmentGroups.ts +++ b/src/store/segmentGroups.ts @@ -11,6 +11,10 @@ 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 DataSelection, getImage, @@ -27,6 +31,7 @@ import { SegmentGroupMetadata, SegmentGroup, } from '../io/state-file/schema'; +import { leafStateId } from '@/src/io/import/dataSource'; import { makeSegmentGroupArchivePath } from '../io/state-file/segmentGroupArchivePath'; import { FileEntry } from '../io/types'; import { ensureSameSpace } from '../io/resample/resample'; @@ -49,6 +54,15 @@ export type SegmentGroupMetadata = { order: number[]; byValue: Record; }; + // Provenance of a job-produced segment group — display provenance only: + // nothing keys dedup or attach semantics off it. 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?: { + jobId: string; + outputId: string; + }; }; export function createLabelmapFromImage(imageData: vtkImageData) { @@ -262,13 +276,18 @@ 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): the DICOM-SEG and + // embedded-metadata lookups have no source then, and the enumeration/default + // path below is the whole decode. async function decodeSegments( - imageId: DataSelection, + imageId: DataSelection | undefined, image: vtkLabelMap, component = 0 ) { const dicomStore = useDICOMStore(); if ( + imageId !== undefined && !isRegularImage(imageId) && dicomStore.volumeInfo[imageId]?.kind !== 'cine' ) { @@ -286,13 +305,38 @@ 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 (issue #6): 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 = + imageId !== undefined + ? imageCacheStore.imageById[imageId]?.segmentMetadata + : undefined; + const described = embedded ? parseSegNrrdMetadata(embedded) : undefined; + + // Distinct nonzero voxel values, ascending — the segment spine (issue #6). + // 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 +346,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 +391,42 @@ 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); + // Loaded-artifact provenance: a URI-loaded child (a job result + // opened via loadAsImport, an external URL load) hands its single verbatim + // URI to the created group(s) so a later save can point back at it. + 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 }, + // Job-produced groups carry a `source: {jobId, outputId}` provenance + // tag so they round-trip the + // .volview.zip; hand-painted / session-restore conversions pass none. + ...(source ? { source } : {}), + }); + return id; + }) + ); } /** @@ -517,60 +582,153 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { 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 (the drop LOGIC is + // unchanged — this only makes the omission visible). + 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 — its lookups go through + // `leafStateId`, the namespace the synthesized leaves were minted under. + // A bare `String(dataSourceId)` key shares the dataset-id + // string space and made the winner leaf-completion-order luck. + // 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 just yields the image. + 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; } - const labelmapResults = await Promise.all( - segmentGroups.map(async (segmentGroup) => { - const { image, storeIdToRemove } = - await loadSegmentGroupImage(segmentGroup); - const labelmapImage = toLabelMap(image); + // 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 + // this pre-await guard is the deterministic fix; 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 hasArtifactLeaf = + segmentGroup.dataSourceId !== undefined && + dataIDMap[leafStateId(segmentGroup.dataSourceId)] !== undefined; + if (!hasArtifactLeaf) { + skipped.push({ + name: segmentGroup.metadata.name, + reason: 'artifact source unavailable', + }); + } + return hasArtifactLeaf; + }); - const id = useIdStore().nextId(); - dataIndex[id] = labelmapImage; - return { id, storeIdToRemove }; - }) + // 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. + const artifactStoreId = (segmentGroup: SegmentGroup) => + segmentGroup.path === undefined + ? dataIDMap[leafStateId(segmentGroup.dataSourceId!)] + : undefined; + const tempStoreIdsToRemove = new Set( + attachable + .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 = 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); + 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 +761,11 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { onImageDeleted((deleted) => { deleted.forEach((parentID) => { - orderByParent.value[parentID]?.forEach((segmentGroupID) => { - removeGroup(segmentGroupID); - }); + // 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..8f5a1064e 100644 --- a/src/store/tools/crop.ts +++ b/src/store/tools/crop.ts @@ -11,6 +11,7 @@ import { defineStore } from 'pinia'; import { arrayEqualsWithComparator } from '@/src/utils'; import { Maybe } from '@/src/types'; import { useImageCacheStore } from '@/src/store/image-cache'; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; import { LPSCroppingPlanes } from '../../types/crop'; import { ImageMetadata } from '../../types/image'; import { StateFile, Manifest } from '../../io/state-file/schema'; @@ -131,6 +132,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..235fe9267 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -52,6 +52,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/useAnnotationTool.ts b/src/store/tools/useAnnotationTool.ts index 73d314fce..b0b3fcb24 100644 --- a/src/store/tools/useAnnotationTool.ts +++ b/src/store/tools/useAnnotationTool.ts @@ -7,6 +7,7 @@ import { } from '@/src/config'; import { removeFromArray } from '@/src/utils'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; import { AnnotationTool, ToolID } from '@/src/types/annotation-tool'; import { useIdStore } from '@/src/store/id'; import { useToolSelectionStore } from '@/src/store/tools/toolSelection'; @@ -115,6 +116,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..2f965c0db 100644 --- a/src/store/views.ts +++ b/src/store/views.ts @@ -11,6 +11,7 @@ import { type LayoutConfig, } from '@/src/utils/layoutParsing'; import type { Manifest, StateFile } from '../io/state-file/schema'; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; const DEFAULT_VIEW_INIT: ViewInfoInit = { type: '2D', @@ -284,10 +285,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 +370,13 @@ export const useViewStore = defineStore('view', () => { applyDisabledViewTypesFilter(); }); + // Delete-base cleanup: a removed dataset must not linger as a view's `dataID` + // (an orphaned binding the next save manifest would carry). Mirrors the + // segment-group / annotation-tool cascade. + 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..112a28794 --- /dev/null +++ b/src/utils/__tests__/fetch.spec.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + $fetch, + setGlobalHeader, + deleteGlobalHeader, + setBearerScope, +} from '@/src/utils/fetch'; +import { RequestPool } from '@/src/core/streaming/requestPool'; + +describe('$fetch origin-aware authentication', () => { + 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 capturedInit = (call = 0): RequestInit => + fetchStub.mock.calls[call][1] as RequestInit; + const capturedHeaders = (call = 0): Headers => + new Headers(capturedInit(call).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('keeps both Authorization and Range on a same-origin 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(); + // RequestInit.headers wins over the Request's own headers. + expect(headers.get('X-Foo')).toBe('from-init'); + // The Request's Authorization overrides the global bearer. + expect(headers.get('Authorization')).toBe('Bearer request'); + }); + + it('never sends the global Authorization header cross-origin (default same-origin scope)', async () => { + await $fetch('https://cross-origin.example/data'); + expect(capturedHeaders().has('Authorization')).toBe(false); + // A cross-origin request keeps native credential behavior (never forced). + expect(capturedInit().credentials).toBeUndefined(); + }); + + it('attaches an explicit token= bearer cross-origin (any-origin scope)', async () => { + // token= is a secret its author already holds: it rides to any origin, the + // documented cross-origin hosted-instance flow. + setBearerScope({ kind: 'any-origin' }); + await $fetch('https://cross-origin.example/data'); + expect(capturedHeaders().get('Authorization')).toBe('Bearer global'); + // Still native credential behavior cross-origin. + expect(capturedInit().credentials).toBeUndefined(); + }); + + it('attaches a tokenUrl-derived bearer only to its issuing origin (and same-origin)', async () => { + // tokenUrl=-derived token is scoped to the endpoint's own origin so a + // crafted link cannot exfiltrate a freshly-minted token to a foreign host. + setBearerScope({ kind: 'origin', origin: 'https://data.example' }); + + await $fetch('https://data.example/volume'); + expect(capturedHeaders(0).get('Authorization')).toBe('Bearer global'); + + await $fetch('https://attacker.example/volume'); + expect(capturedHeaders(1).has('Authorization')).toBe(false); + + await $fetch(sameOrigin('/api/x')); + expect(capturedHeaders(2).get('Authorization')).toBe('Bearer global'); + }); + + it('defaults same-origin credentials to same-origin and honors a stricter omit', async () => { + await $fetch(sameOrigin('/a')); + expect(capturedInit(0).credentials).toBe('same-origin'); + await $fetch(sameOrigin('/b'), { credentials: 'omit' }); + expect(capturedInit(1).credentials).toBe('omit'); + }); +}); + +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/fetch.ts b/src/utils/fetch.ts index dbaeb60ad..b302b4d23 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch.ts @@ -3,12 +3,52 @@ import { Awaitable } from '@vueuse/core'; export const globalHeaders = new Headers(); +// Cross-origin reach of the global `Authorization` bearer. Only the bearer set +// from a `token=`/`tokenUrl=` URL param is ever governed by this; every other +// global header stays same-origin-only regardless (see $fetch). +// +// * `same-origin` (default) — bearer never leaves the deployment's origin. +// * `any-origin` — an explicit `token=` the link author already +// holds; may ride to any origin (the documented +// cross-origin hosted-instance flow). +// * `origin` — a `tokenUrl=`-derived token, scoped to the +// issuing endpoint's own origin so a crafted link +// cannot exfiltrate a freshly-minted token to an +// attacker host. +export type BearerScope = + | { kind: 'same-origin' } + | { kind: 'any-origin' } + | { kind: 'origin'; origin: string }; + +let bearerScope: BearerScope = { kind: 'same-origin' }; + +export function setBearerScope(scope: BearerScope) { + bearerScope = scope; +} + +const bearerAttachesTo = (origin: string): boolean => { + switch (bearerScope.kind) { + case 'any-origin': + return true; + case 'origin': + return origin === bearerScope.origin; + default: + // same-origin only; the same-origin branch in $fetch handles attachment. + return false; + } +}; + export function setGlobalHeader(name: string, value: string) { globalHeaders.set(name, value); } export function deleteGlobalHeader(name: string) { globalHeaders.delete(name); + // The bearer's cross-origin scope is meaningless once the bearer is gone; + // dropping it here keeps the scope from outliving the token that justified it. + if (name.toLowerCase() === 'authorization') { + bearerScope = { kind: 'same-origin' }; + } } /** @@ -27,14 +67,58 @@ function mergeHeaders(base: Headers, supplementInit?: HeadersInit) { return merged; } +/** + * The single origin-aware authenticated browser HTTP primitive. + * + * For a SAME-ORIGIN request it merges every global header (bearer/auth), + * honoring precedence `globalHeaders < Request.headers < RequestInit.headers`, + * and defaults `credentials: 'same-origin'` (a caller may only tighten to + * 'omit'). + * + * For a CROSS-ORIGIN request only the global `Authorization` bearer may ride, + * and only when its recorded `BearerScope` permits this origin (an explicit + * `token=` reaches any origin; a `tokenUrl=`-derived token reaches only its + * issuing origin). Every OTHER global header stays same-origin-only. A Request's + * own headers and the RequestInit headers still layer on top, and the native + * credential behavior is left alone. + * + * Reads `window.location` for the origin check, so it is main-thread only. + */ export const $fetch: typeof fetch = ( input: RequestInfo | URL, init?: RequestInit ): Promise => { - return fetch(input, { - ...init, - headers: mergeHeaders(globalHeaders, init?.headers), - }); + const urlString = input instanceof Request ? input.url : input.toString(); + const requestOrigin = new URL(urlString, window.location.href).origin; + const sameOrigin = requestOrigin === window.location.origin; + + // Same-origin carries every global header; cross-origin carries ONLY the + // Authorization bearer, and only when its scope admits this origin. A + // Request's own headers and the RequestInit headers layer on top (init wins + // over Request wins over global). + let headers: Headers; + if (sameOrigin) { + headers = new Headers(globalHeaders); + } else { + headers = new Headers(); + const authorization = globalHeaders.get('Authorization'); + if (authorization && bearerAttachesTo(requestOrigin)) { + headers.set('Authorization', authorization); + } + } + if (input instanceof Request) headers = mergeHeaders(headers, input.headers); + headers = mergeHeaders(headers, init?.headers); + + if (sameOrigin) { + // Default to same-origin credentials so a cookie-authenticated launch keeps + // working; the only stricter value ('omit') is honored, never widened. + const credentials: RequestCredentials = + init?.credentials === 'omit' ? 'omit' : 'same-origin'; + return fetch(input, { ...init, headers, credentials }); + } + // Cross-origin: only a scope-permitted Authorization bearer rides (attached + // above); native credential behavior preserved. + return fetch(input, { ...init, headers }); }; /** 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..0718a5428 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -1,4 +1,5 @@ -import { setGlobalHeader } from '@/src/utils/fetch'; +import { setGlobalHeader, setBearerScope } from '@/src/utils/fetch'; +import { resolveOrigin } from '@/src/io/originGate'; import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract'; import { UrlParams } from '@vueuse/core'; @@ -14,11 +15,15 @@ export function populateAuthorizationToken() { const urlParams = vtkURLExtract.extractURLParameters() as UrlParams; if (urlParams.token) { + // An explicit token= is a secret its link author already possesses; it may + // ride to any origin (the documented cross-origin hosted-instance flow). setGlobalHeader('Authorization', `Bearer ${urlParams.token}`); + setBearerScope({ kind: 'any-origin' }); } if (urlParams.tokenUrl) { - fetch(String(urlParams.tokenUrl), { + const tokenUrl = String(urlParams.tokenUrl); + fetch(tokenUrl, { method: String(urlParams.tokenUrlMethod || 'GET'), }) .then((response) => { @@ -29,6 +34,15 @@ export function populateAuthorizationToken() { }) .then((text) => { setGlobalHeader('Authorization', `Bearer ${text}`); + // A tokenUrl=-derived token is minted by a (possibly cookie- + // authenticated) endpoint; scope it to that endpoint's own origin so a + // crafted link cannot mint the victim's token and exfiltrate it to an + // attacker host. A malformed/relative tokenUrl falls back to + // same-origin-only. + const origin = resolveOrigin(tokenUrl); + setBearerScope( + origin ? { kind: 'origin', origin } : { kind: 'same-origin' } + ); }) .catch((err) => { console.error('error while fetching token from tokenUrl:', err); 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: { From f766aca06d3f027cc2ded4beb23e1754c5fe4302 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 20 Jul 2026 21:01:53 -0400 Subject: [PATCH 2/5] feat(processing): refine jobs experience and integration --- backend-contract/README.md | 179 ++-- .../fixtures/negative/empty-uris.json | 5 + .../fixtures/negative/wrong-length-color.json | 21 + .../generated/input-value.schema.json | 1 + .../generated/job-results.schema.json | 2 + backend-contract/generated/openapi.json | 409 +------- .../generated/result-intent.schema.json | 2 + .../generated/task-spec.schema.json | 2 + .../processing/__tests__/openapi.spec.ts | 2 + .../processing/__tests__/wire.spec.ts | 27 +- backend-contract/processing/openapi.ts | 61 +- backend-contract/processing/schema-json.ts | 24 +- backend-contract/processing/task-spec.ts | 40 +- backend-contract/processing/wire.ts | 10 +- .../scripts/generate-json-schema.ts | 4 +- backend-contract/scripts/generate-openapi.ts | 4 +- backend-contract/scripts/sync-backend.sh | 89 -- backend-contract/scripts/verify-backend.sh | 67 -- package-lock.json | 79 +- package.json | 1 - src/components/ModulePanel.vue | 36 +- src/components/SegmentGroupControls.vue | 19 - src/components/__tests__/latentGating.spec.ts | 9 +- src/composables/onImageDeleted.ts | 19 +- src/core/progressiveImage.ts | 8 +- src/io/__tests__/segNrrdMetadata.spec.ts | 16 +- .../processingConfigInjection.spec.ts | 8 +- src/io/import/configJson.ts | 7 +- src/io/import/processors/importSingleFile.ts | 7 +- src/io/state-file/serialize.ts | 6 +- src/processing/__tests__/applyResults.spec.ts | 72 +- src/processing/__tests__/config.spec.ts | 35 +- src/processing/__tests__/fakeProvider.ts | 35 + .../__tests__/jobResultReview.spec.ts | 97 -- src/processing/__tests__/store.spec.ts | 373 +++----- src/processing/applyResults.ts | 132 +-- src/processing/components/JobList.vue | 882 +++++++----------- src/processing/components/JobsModule.vue | 160 +--- src/processing/components/TaskForm.vue | 126 ++- src/processing/components/TaskPicker.vue | 1 + .../components/__tests__/JobsModule.spec.ts | 136 +-- .../components/widgets/BooleanWidget.vue | 5 +- .../components/widgets/BoundsWidget.vue | 2 - .../components/widgets/EnumerationWidget.vue | 4 +- .../components/widgets/FileWidget.vue | 73 +- .../components/widgets/NumberWidget.vue | 46 +- .../components/widgets/StringWidget.vue | 2 +- .../widgets/__tests__/NumberWidget.spec.ts | 10 +- src/processing/config.ts | 45 +- .../engine/__tests__/bounds.spec.ts | 8 +- .../engine/__tests__/createProvider.spec.ts | 3 - .../engine/__tests__/formModel.spec.ts | 39 +- .../engine/__tests__/jobHistory.spec.ts | 137 +-- .../engine/__tests__/mintInput.spec.ts | 36 +- .../engine/__tests__/mintLabelmap.spec.ts | 61 -- .../engine/__tests__/resultToIntent.spec.ts | 2 - .../engine/__tests__/transport.spec.ts | 75 +- src/processing/engine/__tests__/wire.spec.ts | 18 - src/processing/engine/bounds.ts | 52 +- src/processing/engine/createProvider.ts | 17 +- src/processing/engine/formModel.ts | 61 +- src/processing/engine/index.ts | 10 - src/processing/engine/jobHistory.ts | 76 +- src/processing/engine/mintInput.ts | 165 ++-- src/processing/engine/mintLabelmap.ts | 153 +-- src/processing/engine/resultToIntent.ts | 23 +- src/processing/engine/taskSpec.ts | 27 +- src/processing/engine/transport.ts | 83 +- src/processing/engine/wire.ts | 174 +--- src/processing/index.ts | 22 +- src/processing/jobResultReview.ts | 57 -- src/processing/store.ts | 445 +++------ src/processing/types.ts | 113 +-- .../__tests__/datasetRemoveCascade.spec.ts | 8 +- src/store/__tests__/datasets-layers.spec.ts | 31 + src/store/__tests__/remote-save-state.spec.ts | 23 +- .../segmentGroupDescriptorlessParity.spec.ts | 10 +- .../segmentGroupRestoreResilience.spec.ts | 29 +- src/store/datasets-images.ts | 7 +- src/store/datasets-layers.ts | 2 +- src/store/image-cache.ts | 13 +- src/store/remote-save-state.ts | 26 +- src/store/segmentGroups.ts | 10 +- src/utils/index.ts | 3 + 84 files changed, 1532 insertions(+), 3887 deletions(-) create mode 100644 backend-contract/fixtures/negative/empty-uris.json create mode 100644 backend-contract/fixtures/negative/wrong-length-color.json delete mode 100755 backend-contract/scripts/sync-backend.sh delete mode 100755 backend-contract/scripts/verify-backend.sh create mode 100644 src/processing/__tests__/fakeProvider.ts delete mode 100644 src/processing/__tests__/jobResultReview.spec.ts delete mode 100644 src/processing/jobResultReview.ts diff --git a/backend-contract/README.md b/backend-contract/README.md index 20e335f8c..679663ffe 100644 --- a/backend-contract/README.md +++ b/backend-contract/README.md @@ -1,139 +1,116 @@ # backend-contract -> **Status: private draft `0.x`.** The artifact version is the OpenAPI -> `info.version` in `generated/openapi.json`, mirrored by `package.json` and kept -> in lockstep by a drift test. This is a private, versioned draft with exactly one -> known consumer (girder_volview, vendored via `sync-backend.sh`); it carries no -> stability promise and no distribution channel. The shapes may change any day. - -The neutral VolView backend contract, published as a self-contained, -backend-decoupled artifact. It defines the **processing** surface — task -discovery, inputs, the job lifecycle, result intents, and personal job history. -The VolView client and any server-side backend (girder_volview today) build -against the shapes defined here; no backend speaks them natively — a backend -**translates** its native task format into this one neutral spec. - -**Bring a new backend online = implement the [OpenAPI](#the-neutral-rest-surface-openapi) -and validate against the fixtures + generated schemas; zero VolView client -change.** Adding a backend is a _conformance exercise_, not a reverse-engineering -of girder_volview: girder_volview is one _consumer_ of this package, not its -owner. It vendors the fixtures + generated schemas (`tests/contract/`) and -validates against them; it defines nothing here. +> **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/ the client↔backend processing contract - task-spec.ts VolView's own zod task-spec schema + processing/ + task-spec.ts VolView's zod task-spec schema wire.ts neutral wire shapes: input value, job status, job history, - and the result-intent vocabulary - openapi.ts the neutral REST surface as an OpenAPI 3.1 document, built - single-source (wire component schemas injected from the zod - codegen) + 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 the schemas + types (NOT the codegen/openapi — - those are imported directly by scripts + tests) - __tests__/ vitest: every fixture validates; negatives fail; the - generated schema + openapi stay in sync with the zod source - generated/ checked-in artifacts the backend validates against: - *.schema.json (one per wire schema) + openapi.json + 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 (all field kinds + a - bounds/enum/UI-hints spec) — no backend-specific source - format (e.g. Slicer XML) lives here; translating a native - task format into this neutral spec is a backend concern - negative/ payloads that MUST fail validation (fail closed) + 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 - sync-backend.sh vendors fixtures + generated/ into a backend repo ``` ## The single normative definition -The **zod sources here are the one normative definition** of the contract. -JSON Schema is deliberately NOT the wire contract — it describes validity but not -rendering, and there is exactly one producer (our backend) and one consumer (our -renderer). 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. +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. ## 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, no -`folderId`, no file ids, no `JobStatus` enum, no proxiable-URL shape. A reviewer -can enumerate what a non-Girder backend must implement **without reading -girder_volview source**: - -| operation | method + neutral 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: the execution record, its results, and its 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` | - -> Naming note: `listJobHistory` / `JobHistorySummary` / `deleteJob` are the -> **job-history** shapes — the user's personal job history, a processing -> concept. - -The component schemas are the wire schemas above (`TaskSpec`, `InputValue`, -`StageInputDescriptor`, `NeutralJobStatus`, `ResultIntent`, `JobHistorySummary`, -…), injected from the same zod codegen, so the published surface can never drift -from the normative definition. The lifecycle is **poll-only** (`getJob`); push -(SSE) is an additive backend-only enhancement, never a neutral client -requirement, so it is not described here. Job-addressed routes (`getJob` / -`getJobResults` / `cancelJob`) 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 declared outputs with -no recorded id plus recorded outputs whose file is no longer readable. +`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 backend projects and the client store consumes at runtime. Girder's -native job status maps onto these with no translation layer, so the canonical -schema is named _to_ the runtime. A backend-side status-conformance test -(girder_volview `tests/test_status_conformance.py`) validates its projected -status against the generated `neutral-job-status` schema so this can't silently -drift. +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 live here and they turn on **separate clocks**: +Two versions on separate clocks: -- The **artifact version** — `package.json` `version` (`private: true`) and the - OpenAPI `info.version`, kept in lockstep by a drift test - (`processing/__tests__/openapi.spec.ts`). It versions _this package as a - published thing_: a draft `0.x` carrying no stability promise. -- The **shape versions** — `INTENT_VOCABULARY_VERSION` (`processing/wire.ts`) and - the task-spec `specVersion`. These version the _wire vocabulary_ so a producer - and the applier can negotiate additive compatibility; they are NOT the artifact - version. +- **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 -backend-contract/scripts/sync-backend.sh [BACKEND_REPO] # regen + vendor into a backend repo ``` -`sync-backend.sh` is the **single writer** of a backend's vendored copy (never -hand-edit `tests/contract/`); it regenerates first, then copies, so a backend's -copy is never stale. The vitest 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. +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/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/wrong-length-color.json b/backend-contract/fixtures/negative/wrong-length-color.json new file mode 100644 index 000000000..043b35cee --- /dev/null +++ b/backend-contract/fixtures/negative/wrong-length-color.json @@ -0,0 +1,21 @@ +{ + "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": { + "jobId": "job-abc123", + "outputId": "outputLabelmap" + } +} diff --git a/backend-contract/generated/input-value.schema.json b/backend-contract/generated/input-value.schema.json index 129f376f8..f394b8bb7 100644 --- a/backend-contract/generated/input-value.schema.json +++ b/backend-contract/generated/input-value.schema.json @@ -9,6 +9,7 @@ "type": "string" }, "uris": { + "minItems": 1, "type": "array", "items": { "type": "string" diff --git a/backend-contract/generated/job-results.schema.json b/backend-contract/generated/job-results.schema.json index 705ba9f5b..e1991baec 100644 --- a/backend-contract/generated/job-results.schema.json +++ b/backend-contract/generated/job-results.schema.json @@ -161,6 +161,8 @@ "type": "string" }, "color": { + "minItems": 4, + "maxItems": 4, "type": "array", "prefixItems": [ { diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index cb043ede2..5989f18b7 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -262,7 +262,7 @@ "tags": [ "job" ], - "summary": "A job's neutral execution and result-readiness status. Poll through Poll until the job reaches a terminal state. Job-addressed: keyed by the job own access control — no context in the path.", + "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", @@ -723,6 +723,8 @@ "type": "boolean" }, "default": { + "minItems": 6, + "maxItems": 6, "type": "array", "prefixItems": [ { @@ -802,6 +804,7 @@ "type": "string" }, "uris": { + "minItems": 1, "type": "array", "items": { "type": "string" @@ -1154,6 +1157,8 @@ "type": "string" }, "color": { + "minItems": 4, + "maxItems": 4, "type": "array", "prefixItems": [ { @@ -1408,97 +1413,7 @@ "jobs": { "type": "array", "items": { - "type": "object", - "properties": { - "jobId": { - "type": "string" - }, - "taskId": { - "type": "string" - }, - "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 + "$ref": "#/components/schemas/JobHistorySummary" } }, "nextCursor": { @@ -1558,309 +1473,7 @@ "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": { - "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": { - "jobId": { - "type": "string" - }, - "outputId": { - "type": "string" - } - }, - "required": [ - "jobId", - "outputId" - ], - "additionalProperties": false - } - }, - "required": [ - "intent", - "id", - "name", - "url" - ], - "additionalProperties": {} - }, - { - "type": "object", - "properties": { - "intent": { - "type": "string", - "const": "restore-state" - }, - "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": {}, - "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": {} - } - ] + "$ref": "#/components/schemas/ResultIntent" } }, "missing": { @@ -1942,7 +1555,7 @@ }, "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 — including `dockerImage`, a display-only implementation label with no neutral semantics.", + "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" @@ -1953,10 +1566,6 @@ "description": { "type": "string" }, - "dockerImage": { - "type": "string", - "description": "Optional advisory implementation label shown in the picker. Display-only: a backend MAY omit it and the client never dispatches on it." - }, "category": { "type": "array", "items": { diff --git a/backend-contract/generated/result-intent.schema.json b/backend-contract/generated/result-intent.schema.json index 017061a9a..a2039586b 100644 --- a/backend-contract/generated/result-intent.schema.json +++ b/backend-contract/generated/result-intent.schema.json @@ -149,6 +149,8 @@ "type": "string" }, "color": { + "minItems": 4, + "maxItems": 4, "type": "array", "prefixItems": [ { diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json index af9d955ee..835f2a11a 100644 --- a/backend-contract/generated/task-spec.schema.json +++ b/backend-contract/generated/task-spec.schema.json @@ -330,6 +330,8 @@ "type": "boolean" }, "default": { + "minItems": 6, + "maxItems": 6, "type": "array", "prefixItems": [ { diff --git a/backend-contract/processing/__tests__/openapi.spec.ts b/backend-contract/processing/__tests__/openapi.spec.ts index 37d66171f..abee1a297 100644 --- a/backend-contract/processing/__tests__/openapi.spec.ts +++ b/backend-contract/processing/__tests__/openapi.spec.ts @@ -154,8 +154,10 @@ const FORBIDDEN = [ { 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: /(? [f.name, f.data]) @@ -50,6 +51,11 @@ describe('input value fixtures', () => { 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', () => { @@ -255,6 +261,25 @@ describe('result intent fixtures', () => { 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); + }); }); // --------------------------------------------------------------------------- diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts index 6c32017df..d91ec602a 100644 --- a/backend-contract/processing/openapi.ts +++ b/backend-contract/processing/openapi.ts @@ -17,8 +17,6 @@ // 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). -// -// House rules: functional style; `type`, not `interface`. // --------------------------------------------------------------------------- import { generateJsonSchemas, type GeneratedSchemaName } from './schema-json'; @@ -56,14 +54,46 @@ const stripDialect = (schema: unknown): Record => { const wireComponentSchemas = (): Record => { const generated = generateJsonSchemas(); - return Object.fromEntries( - (Object.keys(WIRE_COMPONENTS) as GeneratedSchemaName[]).map((name) => [ - WIRE_COMPONENTS[name], - stripDialect(generated[name]), - ]) + 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) // @@ -87,19 +117,11 @@ const envelopeComponentSchemas = (): Record => ({ '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 — including `dockerImage`, a display-only ' + - 'implementation label with no neutral semantics.', + 'never dispatches on it.', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, - dockerImage: { - type: 'string', - description: - 'Optional advisory implementation label shown in the picker. ' + - 'Display-only: a backend MAY omit it and the client never dispatches ' + - 'on it.', - }, category: { type: 'array', items: { type: 'string' } }, }, required: ['id', 'title'], @@ -351,9 +373,10 @@ const paths = (): Record => ({ operationId: 'getJob', tags: ['job'], summary: - "A job's neutral execution and result-readiness status. Poll through " + - 'Poll until the job reaches a terminal state. Job-addressed: keyed by the job ' + - 'own access control — no context in the path.', + "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': { diff --git a/backend-contract/processing/schema-json.ts b/backend-contract/processing/schema-json.ts index abdb6b58c..8397221fb 100644 --- a/backend-contract/processing/schema-json.ts +++ b/backend-contract/processing/schema-json.ts @@ -43,11 +43,33 @@ const schemas = { export type GeneratedSchemaName = keyof typeof schemas; +// 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 => Object.fromEntries( Object.entries(schemas).map(([name, schema]) => [ name, - z.toJSONSchema(schema, { unrepresentable: 'any' }), + closeTupleLengths(z.toJSONSchema(schema, { unrepresentable: 'any' })), ]) ) as Record; diff --git a/backend-contract/processing/task-spec.ts b/backend-contract/processing/task-spec.ts index f21e45db1..9822f7990 100644 --- a/backend-contract/processing/task-spec.ts +++ b/backend-contract/processing/task-spec.ts @@ -8,13 +8,8 @@ // fixtures under `fixtures/` are the interchange format both the client (zod) // and the backend (a JSON-Schema generated from this source) validate against. // -// JSON Schema is deliberately NOT the wire contract: it describes validity -// but not rendering, and there is exactly one producer (our backend) and one -// consumer (our renderer). -// -// House rules: functional style; `type`, not `interface`. Additive-only — -// new fields (guidance / interactive semantics, future extensions) must -// extend this schema AGAINST `specVersion`, never mutate it. +// Additive-only: new fields must extend this schema AGAINST `specVersion`, +// never mutate it. // --------------------------------------------------------------------------- import { z } from 'zod'; @@ -37,8 +32,7 @@ export const TYPE_TAG_LABELMAP = 'labelmap'; export const typeTagSchema = z.string(); // 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; maps -// from Slicer XML ``). +// The value carried by a `bounds` parameter (bound from the crop tool). export const boundsSchema = z.tuple([ z.number(), z.number(), @@ -51,10 +45,9 @@ 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 negative fixture exercises this). 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. +// 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', @@ -72,8 +65,8 @@ export type ParameterKind = (typeof PARAMETER_KINDS)[number]; // 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 with no Slicer-XML source — the renderer -// picks a default widget from `kind` when it is absent. +// is an optional renderer override — the renderer picks a default widget from +// `kind` when it is absent. const paramCommon = { id: z.string(), title: z.string().optional(), @@ -114,9 +107,9 @@ const boolParam = z.object({ default: z.boolean().optional(), }); -// Enum options are string OR number: Slicer integer/float enumerations emit -// numeric members (`` etc.), and coercing them to strings -// at the boundary would lose the type the CLI expects back at submit. +// 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({ @@ -126,9 +119,9 @@ const enumParam = z.object({ default: enumOptionSchema.optional(), }); -// Imaging-native field kind: the input. `accepts` is a list of the open -// type tags this input binds (absent/`scalar` `` → `["image"]`, -// `` → `["labelmap"]`; the backend authors the mapping). +// 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, @@ -156,9 +149,8 @@ const isNumericKind = (kind: ParameterKind) => kind === 'int' || kind === 'float'; // Cross-field constraint checks layered on top of the discriminated union. -// These are the constraints the negative "constraint-violation" fixture -// exercises. JSON Schema cannot express them; they are the zod side's extra -// rigor over the structural JSON-Schema view generated for the backend. +// JSON Schema cannot express them; they are the zod side's extra rigor over +// the structural JSON-Schema view generated for the backend. export const taskParameterSchema = parameterUnion .refine( (p) => diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index 8870bd51c..4a7c4c6b2 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -8,8 +8,6 @@ // 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. -// -// House rules: functional style; `type`, not `interface`. // --------------------------------------------------------------------------- import { z } from 'zod'; @@ -25,11 +23,13 @@ export const INTENT_VOCABULARY_VERSION = 1; // 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). +// 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()), + uris: z.array(z.string()).min(1), }); export type InputValue = z.infer; @@ -56,7 +56,7 @@ export type StageInputDescriptor = z.infer; // Exactly these five states, named to match what the backend projects and the // client store consumes at runtime (`pending | running | success | error | -// cancelled`): girder's native job status maps onto these with no translation +// 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. diff --git a/backend-contract/scripts/generate-json-schema.ts b/backend-contract/scripts/generate-json-schema.ts index bc6293115..ac3d66789 100644 --- a/backend-contract/scripts/generate-json-schema.ts +++ b/backend-contract/scripts/generate-json-schema.ts @@ -5,8 +5,8 @@ // 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`. `scripts/sync-backend.sh` copies the -// fixtures + generated schemas into girder_volview. +// `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'; diff --git a/backend-contract/scripts/generate-openapi.ts b/backend-contract/scripts/generate-openapi.ts index e73299971..cd99fa314 100644 --- a/backend-contract/scripts/generate-openapi.ts +++ b/backend-contract/scripts/generate-openapi.ts @@ -7,8 +7,8 @@ // 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; `scripts/sync-backend.sh` vendors -// it (alongside the fixtures + generated schemas) into girder_volview. +// 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'; diff --git a/backend-contract/scripts/sync-backend.sh b/backend-contract/scripts/sync-backend.sh deleted file mode 100755 index f86a2cedd..000000000 --- a/backend-contract/scripts/sync-backend.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# Vendor the backend-contract fixtures + generated JSON Schemas into the -# girder_volview backend so its tests consume the SAME artifacts (one -# normative source in VolView, a synced copy in the backend — never -# hand-edited). -# -# Usage: -# backend-contract/scripts/sync-backend.sh -# BACKEND_REPO (required): path to the girder_volview checkout to vendor into. -set -euo pipefail - -here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -pkg_dir="$(dirname "$here")" -if [ $# -lt 1 ]; then - echo "usage: backend-contract/scripts/sync-backend.sh " >&2 - exit 2 -fi -backend_repo="$1" -dest="$backend_repo/tests/contract" - -if [ ! -d "$backend_repo" ]; then - echo "backend repo not found: $backend_repo" >&2 - exit 1 -fi - -# Regenerate the JSON Schemas + the published OpenAPI first so the copy is never -# stale (both land in generated/, both are vendored below). One entry point wraps -# BOTH generators so the pipeline cannot be run half-way. -(cd "$pkg_dir/.." && npm run --silent contract:generate) - -# Refuse a dirty sync BEFORE mutating the destination. A dirty contract subtree -# would vendor artifacts that cannot be reconstructed from client_git_sha, -# silently breaking provenance. Checking the CANONICAL subtree here — after -# regeneration, so a stale checked-in generated/ also trips it, and before any -# rm/cp — guarantees a refused sync leaves the backend tree untouched (and lets -# client_git_dirty below be a constant false). -if [ -n "$(git -C "$pkg_dir" status --porcelain -- "$pkg_dir")" ]; then - echo "backend-contract subtree has uncommitted changes; commit (and regen) before syncing" >&2 - git -C "$pkg_dir" status --porcelain -- "$pkg_dir" >&2 - exit 1 -fi - -mkdir -p "$dest" -rm -rf "$dest/fixtures" "$dest/generated" -cp -R "$pkg_dir/fixtures" "$dest/fixtures" -cp -R "$pkg_dir/generated" "$dest/generated" - -# Write a content-addressed manifest over EVERYTHING copied so the vendored copy -# is tamper-evident: the backend's test_contract_manifest re-hashes -# these files against it, catching a hand-edited fixture AND a stale/partial sync -# (a file present-but-unlisted or listed-but-missing both fail the re-hash). The -# manifest itself is excluded (it lives at the tests/contract root, outside the -# copied subtrees). Sorted for a deterministic, diff-friendly manifest. -( - cd "$dest" - LC_ALL=C find fixtures generated -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum > MANIFEST.sha256 -) - -# --- Provenance stamp: make the vendored copy self-describing so a -# reader (and the backend's own test_contract_source) can see WHICH client commit -# / version it was synced from, and self-certify the tree against a single digest. -# This does NOT by itself prove the copy is CURRENT -- that is the client's -# verify-backend.sh step, the only checker that can see both trees. Written OUTSIDE -# the manifested subtrees, so it is not covered by MANIFEST.sha256 (which hashes -# only those subtrees); its own integrity rides tree_sha256 below. -contract_version="$(node -p "require('$dest/generated/openapi.json').info.version")" -spec_version="$(grep -oE 'SPEC_VERSION = [0-9]+' "$pkg_dir/processing/task-spec.ts" | grep -oE '[0-9]+')" -intent_version="$(grep -oE 'INTENT_VOCABULARY_VERSION = [0-9]+' "$pkg_dir/processing/wire.ts" | grep -oE '[0-9]+')" -client_sha="$(git -C "$pkg_dir" rev-parse --short HEAD)" -# Always false: the pre-copy guard above already refused any dirty subtree, so a -# sync that reaches this point is provably clean. -client_dirty=false -# tree_sha256 = sha256 of MANIFEST.sha256, which already provably equals the -# copied tree, so hashing it transitively certifies fixtures/ + generated/. -tree_sha256="$(sha256sum "$dest/MANIFEST.sha256" | cut -d' ' -f1)" - -cat > "$dest/SOURCE.txt" < $dest" diff --git a/backend-contract/scripts/verify-backend.sh b/backend-contract/scripts/verify-backend.sh deleted file mode 100755 index 582db5c8b..000000000 --- a/backend-contract/scripts/verify-backend.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# The ACTUAL cross-repo drift guard. The backend's own tests can only -# self-certify its vendored copy (present, well-formed, internally consistent); -# only the client (the normative source) can prove that copy is CURRENT, and only -# where the backend tree is reachable. Regenerate the client contract, then diff -# the canonical client tree against the backend's vendored copy. A nonzero exit -# means the backend is stale -- someone regenerated the contract without re-running -# sync-backend.sh. -# -# backend-contract/scripts/verify-backend.sh [--no-generate] -# BACKEND_REPO (required): path to the girder_volview checkout to diff against. -# -# --no-generate: skip regeneration and diff the already-checked-in artifacts. -# Use this when running from an INSTALLED npm package (node_modules/volview/ -# backend-contract), where the shipped artifacts are guaranteed fresh by the -# client's own CI (the generated-schema vitest spec + `npm test` on every -# commit), so no zod/tsx toolchain is needed at the consumer. Without it, the -# script regenerates first (the in-repo path, where source can be edited). -# -# CI-enforced: the backend repo's `paired-contract` workflow job checks out an -# explicitly pinned VolView revision alongside this backend and runs this script -# (see girder_volview `.github/workflows/ci.yml`), so a stale vendored copy fails -# CI, not only a local pre-push check. It also remains runnable on a dev machine. -# An explicitly supplied backend path is a required gate: a missing contract tree -# is an error, not a skip. -set -euo pipefail - -here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -pkg_dir="$(dirname "$here")" -generate=1 -if [ "${1:-}" = "--no-generate" ]; then - generate= - shift -fi -if [ $# -lt 1 ]; then - echo "usage: backend-contract/scripts/verify-backend.sh [--no-generate] " >&2 - exit 2 -fi -backend_repo="$1" -dest="$backend_repo/tests/contract" - -if [ ! -d "$dest" ]; then - echo "verify-backend: backend contract tree not present: $dest" >&2 - exit 1 -fi - -# Regenerate so we compare against fresh output, never a stale working copy. -# Skipped under --no-generate (installed-package path): the shipped artifacts are -# already the client's freshly-generated, CI-verified output. -if [ -n "$generate" ]; then - (cd "$pkg_dir/.." && npm run --silent contract:generate) -fi - -status=0 -for sub in fixtures generated; do - if ! diff -r "$pkg_dir/$sub" "$dest/$sub"; then - status=1 - fi -done - -if [ "$status" -ne 0 ]; then - echo "" >&2 - echo "BACKEND CONTRACT IS STALE: the vendored copy differs from the client" >&2 - echo "contract above. Run: backend-contract/scripts/sync-backend.sh" >&2 - exit 1 -fi -echo "verify-backend: backend vendored contract is in sync." diff --git a/package-lock.json b/package-lock.json index 97604eb99..384f6aa88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,6 @@ "": { "name": "volview", "version": "0.0.0", - "dependencies": { - "zod": "^4.1.13" - }, "devDependencies": { "@commitlint/cli": "^21.0.0", "@commitlint/config-conventional": "^21.0.0", @@ -86,7 +83,8 @@ "wdio-html-nice-reporter": "^8.1.7", "wdio-wait-for": "^3.1.0", "webdriverio": "^9.20.1", - "yorkie": "^2.0.0" + "yorkie": "^2.0.0", + "zod": "^4.1.13" } }, "node_modules/@algolia/abtesting": { @@ -21787,33 +21785,6 @@ "typescript-string-operations": "^1.5.0" } }, - "node_modules/wdio-html-nice-reporter/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/wdio-html-nice-reporter/node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -21824,21 +21795,6 @@ "node": ">= 6" } }, - "node_modules/wdio-html-nice-reporter/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/wdio-html-nice-reporter/node_modules/nunjucks": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", @@ -21883,36 +21839,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wdio-html-nice-reporter/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/wdio-html-nice-reporter/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/wdio-wait-for": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/wdio-wait-for/-/wdio-wait-for-3.1.1.tgz", @@ -22981,6 +22907,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index d96bb7588..a76661e56 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "serve": "npm run dev", "prebuild": "patch-package", "contract:generate": "tsx backend-contract/scripts/generate-json-schema.ts && tsx backend-contract/scripts/generate-openapi.ts", - "contract:verify-backend": "backend-contract/scripts/verify-backend.sh", "build": "vue-tsc --noEmit && vite build", "build:analyze": "cross-env ANALYZE_BUNDLE=1 npm run build", "test": "vitest", diff --git a/src/components/ModulePanel.vue b/src/components/ModulePanel.vue index 19bc287d9..d8457270f 100644 --- a/src/components/ModulePanel.vue +++ b/src/components/ModulePanel.vue @@ -41,14 +41,7 @@ diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index 7041c30c7..5efbd3165 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -15,8 +15,8 @@ > - mdi-play-circle-outline - Run a Task + mdi-console-line + Run a job mdi-history Jobs + + {{ historyCount }} in history + @@ -88,6 +95,7 @@ diff --git a/src/processing/components/widgets/NumberWidget.vue b/src/processing/components/widgets/NumberWidget.vue index 0087413f3..078349368 100644 --- a/src/processing/components/widgets/NumberWidget.vue +++ b/src/processing/components/widgets/NumberWidget.vue @@ -1,13 +1,31 @@