Skip to content

Commit 67bdf9d

Browse files
committed
Implement Bloom n1 work-control layer, metadata normalization, and modern action fixes
1 parent f08ea64 commit 67bdf9d

File tree

9 files changed

+1648
-57
lines changed

9 files changed

+1648
-57
lines changed

ATLAS_BLOOM_API_GUIDANCE.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,27 @@ Current defaults and constraints:
1919
- `saliva` is not currently available as a specimen template in Bloom.
2020
- No order-alert subscription/webhook endpoint exists yet. Use polling (documented below).
2121

22+
Beta contract guardrails:
23+
24+
1. The atomic business/fulfillment/reporting unit is `TRF.test`; `TRF` is rollup context only.
25+
2. External contracts are EUID-only (do not depend on UUIDs).
26+
3. Container context may link to multiple tests on the same TRF; direct container-linked tests/process-items are the authoritative processing set.
27+
28+
Beta work-control endpoints (queue claim/reservation/consume):
29+
30+
1. `POST /api/v1/external/atlas/beta/queues/{queue_name}/items/{material_euid}/claim`
31+
2. `POST /api/v1/external/atlas/beta/claims/{claim_euid}/release`
32+
3. `POST /api/v1/external/atlas/beta/materials/{material_euid}/reservations`
33+
4. `POST /api/v1/external/atlas/beta/reservations/{reservation_euid}/release`
34+
5. `POST /api/v1/external/atlas/beta/materials/{material_euid}/consume`
35+
36+
Execution metadata normalization for extraction/QC/library-prep/pool/run and work-control events:
37+
38+
1. canonical keys: `operator`, `instrument_euid`, `method_version`, `reagent_euid`
39+
2. unknown keys are preserved
40+
3. empty strings are stripped
41+
4. `instrument_euid` and `reagent_euid` (if provided) are validated and emitted as lineage (`beta_used_instrument`, `beta_used_reagent`)
42+
2243
## 2. Authentication Prerequisites
2344

2445
Atlas should use a Bloom-issued bearer token:
@@ -323,7 +344,7 @@ Retry guidance for Atlas:
323344
|---|---|
324345
| `container_euid` | Required to re-link/update specimen placement later |
325346
| `specimen_euid` | Primary key for Bloom specimen lifecycle operations |
326-
| `specimen_uuid` | Secondary stable identifier for traceability |
347+
| UUID fields | Not part of the public beta contract; persist EUIDs only |
327348
| `atlas_refs` | Cross-system reconciliation and lookup |
328349
| `idempotency_key` used | Safe retries without duplication |
329350

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Canonical queues:
3838
- `ont_start_seq_run`
3939

4040
Atlas records intake outcomes first. Bloom accepts only Atlas-approved material, links that material to Atlas TRF/Test/process-item context through explicit graph-linked reference objects, and preserves lineage from specimen/container through plate and well placement, library prep, pooling, sequencing run creation, and sequenced library assignment.
41+
The atomic business/fulfillment/reporting unit is `TRF.test`; `TRF` is a rollup container across child tests.
4142
Accepted-material ingress queue membership is applied to the physical container (`container_euid`), with specimen queue reads falling back to containing-container queue state when needed.
4243

4344
Ursa resolves the canonical sequencing unit through Bloom with:
@@ -79,6 +80,11 @@ The queue-driven beta endpoints are:
7980

8081
- `POST /api/v1/external/atlas/beta/materials`
8182
- `POST /api/v1/external/atlas/beta/queues/{queue_name}/items/{material_euid}`
83+
- `POST /api/v1/external/atlas/beta/queues/{queue_name}/items/{material_euid}/claim`
84+
- `POST /api/v1/external/atlas/beta/claims/{claim_euid}/release`
85+
- `POST /api/v1/external/atlas/beta/materials/{material_euid}/reservations`
86+
- `POST /api/v1/external/atlas/beta/reservations/{reservation_euid}/release`
87+
- `POST /api/v1/external/atlas/beta/materials/{material_euid}/consume`
8288
- `POST /api/v1/external/atlas/beta/extractions`
8389
- `POST /api/v1/external/atlas/beta/post-extract-qc`
8490
- `POST /api/v1/external/atlas/beta/library-prep`
@@ -87,6 +93,15 @@ The queue-driven beta endpoints are:
8793
- `GET /api/v1/external/atlas/beta/runs/{run_euid}/resolve?flowcell_id=...&lane=...&library_barcode=...`
8894
- `POST /api/v1/external/atlas/tests/{test_euid}/status-events`
8995

96+
Execution metadata for extraction/QC/library-prep/pool/run and work-control events is normalized in-place:
97+
98+
- canonical keys: `operator`, `instrument_euid`, `method_version`, `reagent_euid`
99+
- empty strings are stripped
100+
- unknown keys are preserved
101+
- `instrument_euid` and `reagent_euid` (when provided) are validated and written as lineage:
102+
- `beta_used_instrument`
103+
- `beta_used_reagent`
104+
90105
## Embedded TapDB Admin Mount
91106

92107
Bloom mounts the TapDB admin FastAPI surface inside the same Bloom server process at:

bloom_lims/api/v1/beta_lab.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from bloom_lims.domain.beta_lab import BetaLabService
1616
from bloom_lims.schemas.beta_lab import (
1717
BetaAcceptedMaterialCreateRequest,
18+
BetaClaimCreateRequest,
19+
BetaClaimReleaseRequest,
20+
BetaClaimResponse,
21+
BetaConsumeMaterialRequest,
22+
BetaConsumeMaterialResponse,
1823
BetaExtractionCreateRequest,
1924
BetaExtractionResponse,
2025
BetaLibraryPrepCreateRequest,
@@ -26,12 +31,15 @@
2631
BetaPostExtractQCResponse,
2732
BetaQueueTransitionRequest,
2833
BetaQueueTransitionResponse,
34+
BetaReservationCreateRequest,
35+
BetaReservationReleaseRequest,
36+
BetaReservationResponse,
2937
BetaRunCreateRequest,
3038
BetaRunResolutionResponse,
3139
BetaRunResponse,
40+
BetaSpecimenUpdateRequest,
3241
BetaTubeCreateRequest,
3342
BetaTubeResponse,
34-
BetaSpecimenUpdateRequest,
3543
BetaTubeUpdateRequest,
3644
)
3745

@@ -185,6 +193,149 @@ async def move_material_to_queue(
185193
service.close()
186194

187195

196+
@router.post(
197+
"/queues/{queue_name}/items/{material_euid}/claim",
198+
response_model=BetaClaimResponse,
199+
)
200+
async def claim_material_in_queue(
201+
queue_name: str,
202+
material_euid: str,
203+
payload: BetaClaimCreateRequest,
204+
user: APIUser = Depends(require_external_write),
205+
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
206+
):
207+
service = BetaLabService(app_username=user.email)
208+
try:
209+
return service.claim_material_in_queue(
210+
material_euid=material_euid,
211+
queue_name=queue_name,
212+
metadata=payload.metadata,
213+
idempotency_key=idempotency_key,
214+
)
215+
except ValueError as exc:
216+
raise HTTPException(
217+
status_code=_status_for_value_error(exc), detail=str(exc)
218+
) from exc
219+
except Exception as exc:
220+
logger.exception("Failed claiming Bloom beta queue material")
221+
raise HTTPException(status_code=500, detail=str(exc)) from exc
222+
finally:
223+
service.close()
224+
225+
226+
@router.post("/claims/{claim_euid}/release", response_model=BetaClaimResponse)
227+
async def release_claim(
228+
claim_euid: str,
229+
payload: BetaClaimReleaseRequest,
230+
user: APIUser = Depends(require_external_write),
231+
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
232+
):
233+
service = BetaLabService(app_username=user.email)
234+
try:
235+
return service.release_claim(
236+
claim_euid=claim_euid,
237+
reason=payload.reason,
238+
metadata=payload.metadata,
239+
idempotency_key=idempotency_key,
240+
)
241+
except ValueError as exc:
242+
raise HTTPException(
243+
status_code=_status_for_value_error(exc), detail=str(exc)
244+
) from exc
245+
except Exception as exc:
246+
logger.exception("Failed releasing Bloom beta queue claim")
247+
raise HTTPException(status_code=500, detail=str(exc)) from exc
248+
finally:
249+
service.close()
250+
251+
252+
@router.post(
253+
"/materials/{material_euid}/reservations",
254+
response_model=BetaReservationResponse,
255+
)
256+
async def reserve_material(
257+
material_euid: str,
258+
payload: BetaReservationCreateRequest,
259+
user: APIUser = Depends(require_external_write),
260+
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
261+
):
262+
service = BetaLabService(app_username=user.email)
263+
try:
264+
return service.reserve_material(
265+
material_euid=material_euid,
266+
reason=payload.reason,
267+
metadata=payload.metadata,
268+
idempotency_key=idempotency_key,
269+
)
270+
except ValueError as exc:
271+
raise HTTPException(
272+
status_code=_status_for_value_error(exc), detail=str(exc)
273+
) from exc
274+
except Exception as exc:
275+
logger.exception("Failed reserving Bloom beta material")
276+
raise HTTPException(status_code=500, detail=str(exc)) from exc
277+
finally:
278+
service.close()
279+
280+
281+
@router.post(
282+
"/reservations/{reservation_euid}/release",
283+
response_model=BetaReservationResponse,
284+
)
285+
async def release_reservation(
286+
reservation_euid: str,
287+
payload: BetaReservationReleaseRequest,
288+
user: APIUser = Depends(require_external_write),
289+
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
290+
):
291+
service = BetaLabService(app_username=user.email)
292+
try:
293+
return service.release_reservation(
294+
reservation_euid=reservation_euid,
295+
reason=payload.reason,
296+
metadata=payload.metadata,
297+
idempotency_key=idempotency_key,
298+
)
299+
except ValueError as exc:
300+
raise HTTPException(
301+
status_code=_status_for_value_error(exc), detail=str(exc)
302+
) from exc
303+
except Exception as exc:
304+
logger.exception("Failed releasing Bloom beta material reservation")
305+
raise HTTPException(status_code=500, detail=str(exc)) from exc
306+
finally:
307+
service.close()
308+
309+
310+
@router.post(
311+
"/materials/{material_euid}/consume",
312+
response_model=BetaConsumeMaterialResponse,
313+
)
314+
async def consume_material(
315+
material_euid: str,
316+
payload: BetaConsumeMaterialRequest,
317+
user: APIUser = Depends(require_external_write),
318+
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
319+
):
320+
service = BetaLabService(app_username=user.email)
321+
try:
322+
return service.consume_material(
323+
material_euid=material_euid,
324+
reason=payload.reason,
325+
metadata=payload.metadata,
326+
idempotency_key=idempotency_key,
327+
)
328+
except ValueError as exc:
329+
raise HTTPException(
330+
status_code=_status_for_value_error(exc), detail=str(exc)
331+
) from exc
332+
except Exception as exc:
333+
logger.exception("Failed consuming Bloom beta material")
334+
raise HTTPException(status_code=500, detail=str(exc)) from exc
335+
finally:
336+
service.close()
337+
338+
188339
@router.post("/extractions", response_model=BetaExtractionResponse)
189340
async def create_extraction(
190341
payload: BetaExtractionCreateRequest,

bloom_lims/core/action_execution.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ def _resolve_action_definition(
181181
instance: Any,
182182
action_group: str,
183183
action_key: str,
184+
*,
185+
allow_missing_template_uid: bool = False,
184186
) -> tuple[dict[str, Any], str]:
185187
action_groups = (instance.json_addl or {}).get("action_groups", {})
186188
if not isinstance(action_groups, dict):
@@ -234,6 +236,14 @@ def _resolve_action_definition(
234236
resolved = copy.deepcopy(action_data)
235237
if not isinstance(resolved.get("captured_data"), dict):
236238
resolved["captured_data"] = {}
239+
if not resolved.get("action_template_uid") and not allow_missing_template_uid:
240+
raise ActionExecutionError(
241+
status_code=409,
242+
detail=(
243+
f"Action {action_key} is missing TapDB template metadata "
244+
"(action_template_uid)."
245+
),
246+
)
237247
return resolved, matched_key
238248

239249

@@ -454,12 +464,34 @@ def execute_action_for_instance(
454464
detail=f"Object not found: {request_data.euid}",
455465
)
456466

457-
action_definition = _resolve_action_definition(
458-
instance,
459-
request_data.action_group,
460-
request_data.action_key,
461-
)
462-
action_definition, matched_action_key = action_definition
467+
try:
468+
resolved_action = _resolve_action_definition(
469+
instance,
470+
request_data.action_group,
471+
request_data.action_key,
472+
allow_missing_template_uid=True,
473+
)
474+
except TypeError as exc:
475+
# Backward-compatible with tests/patches that replace the resolver
476+
# with a 3-arg callable.
477+
if "allow_missing_template_uid" not in str(exc):
478+
raise
479+
resolved_action = _resolve_action_definition(
480+
instance,
481+
request_data.action_group,
482+
request_data.action_key,
483+
)
484+
if isinstance(resolved_action, tuple):
485+
action_definition, matched_action_key = resolved_action
486+
elif isinstance(resolved_action, dict):
487+
# Backward-compatible for tests/monkeypatches that return only the definition.
488+
action_definition = resolved_action
489+
matched_action_key = request_data.action_key
490+
else:
491+
raise ActionExecutionError(
492+
status_code=500,
493+
detail="Invalid action definition payload",
494+
)
463495

464496
if not action_definition.get("action_template_uid"):
465497
recovered_uid = _resolve_action_template_uid(

bloom_lims/domain/beta_actions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@
3939
"name": "Move Material To Queue",
4040
"description": "Moves Bloom beta material into a canonical beta queue.",
4141
},
42+
"claim_material_in_queue": {
43+
"name": "Claim Material In Queue",
44+
"description": "Creates a work claim for queued beta material.",
45+
},
46+
"release_claim": {
47+
"name": "Release Claim",
48+
"description": "Releases a work claim for queued beta material.",
49+
},
50+
"reserve_material": {
51+
"name": "Reserve Material",
52+
"description": "Creates a reservation lock on beta material.",
53+
},
54+
"release_reservation": {
55+
"name": "Release Reservation",
56+
"description": "Releases a reservation lock on beta material.",
57+
},
58+
"consume_material": {
59+
"name": "Consume Material",
60+
"description": "Records beta material consumption to prevent stage reuse.",
61+
},
4262
"create_extraction": {
4363
"name": "Create Extraction",
4464
"description": "Creates a Bloom beta extraction output from queued material.",

0 commit comments

Comments
 (0)