launchhelm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
launchhelm/client.py ADDED
@@ -0,0 +1,1660 @@
1
+ """Synchronous HTTP client for the LaunchHelm v1 API.
2
+
3
+ Contract: HTTPS is required outside loopback. Request bodies that are already
4
+ encoded bytes are sent unchanged so telemetry fingerprints stay stable.
5
+ Responses larger than 8 MiB are rejected. Redirects are not followed.
6
+
7
+ Failure: ``LaunchHelmError`` for transport uncertainty (retryable), HTTP
8
+ problem documents, and invalid JSON. ``ValueError`` for a malformed base URL
9
+ or organization id.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import uuid
17
+ from collections.abc import Sequence
18
+ from datetime import UTC, datetime, timedelta
19
+ from pathlib import Path
20
+ from typing import Protocol
21
+ from urllib.parse import urlparse
22
+
23
+ import httpx
24
+
25
+ from ._version import __version__
26
+ from .errors import LaunchHelmError
27
+ from .models import (
28
+ AckInput,
29
+ AckResult,
30
+ AnalysisClaimInput,
31
+ AnalysisLeaseInput,
32
+ AnalysisReport,
33
+ AnalysisRunInput,
34
+ DeliveryClaimInput,
35
+ DeliveryPage,
36
+ Document,
37
+ ExportInput,
38
+ InvitationInput,
39
+ InvitationResendInput,
40
+ NotificationDispatchResult,
41
+ NotificationInput,
42
+ NotificationReadInput,
43
+ PublicDocument,
44
+ PublicDocumentPage,
45
+ PublicViewInput,
46
+ PublicViewPublication,
47
+ PublicViewToken,
48
+ PublicViewUpdate,
49
+ Query,
50
+ QueryResult,
51
+ RetentionHoldInput,
52
+ RetentionPlan,
53
+ RetentionRestoreInput,
54
+ Scope,
55
+ UsageInput,
56
+ UsageReport,
57
+ )
58
+ from .store import TelemetryBatch
59
+ from .transport import AbortableTransport
60
+ from .values import (
61
+ JSONObject,
62
+ JSONValue,
63
+ as_json_value,
64
+ encode,
65
+ json_object,
66
+ string_object,
67
+ )
68
+
69
+ MAX_RESPONSE_BYTES = 8 << 20
70
+ UPLOAD_TIMEOUT_SECONDS = 120
71
+ MIN_PART_SIZE = 5 << 20
72
+ MAX_PART_SIZE = 64 << 20
73
+ MAX_PARTS = 10000
74
+
75
+
76
+ class RuntimeClient(Protocol):
77
+ """Subset of ``Client`` required by ``Runtime``.
78
+
79
+ Contract: implementations must allow concurrent calls from the runtime's
80
+ control thread, uploader thread, and caller thread. ``httpx.Client``
81
+ satisfies this; test doubles must not share mutable response buffers
82
+ without their own locking.
83
+
84
+ Failure: methods raise ``LaunchHelmError`` using the same codes as
85
+ ``Client``.
86
+ """
87
+
88
+ base_url: str
89
+ organization_id: str
90
+
91
+ def get_document(self, document_id: str, *, revision: str = "") -> Document:
92
+ """See ``Client.get_document``."""
93
+
94
+ def connect(
95
+ self,
96
+ participant_id: str,
97
+ instance_id: str,
98
+ *,
99
+ safe_points: tuple[str, ...] = (),
100
+ attempt_id: str = "",
101
+ ) -> JSONObject:
102
+ """See ``Client.connect``."""
103
+
104
+ def heartbeat(
105
+ self, participant_id: str, epoch: str, *, safe_points: tuple[str, ...] = ()
106
+ ) -> JSONObject:
107
+ """See ``Client.heartbeat``."""
108
+
109
+ def claim(self, participant_id: str, epoch: str, *, wait_ms: int = 0) -> JSONObject | None:
110
+ """See ``Client.claim``."""
111
+
112
+ def create_document(self, kind: str, scope: Scope, spec: JSONObject) -> Document:
113
+ """See ``Client.create_document``."""
114
+
115
+ def list_documents(
116
+ self,
117
+ *,
118
+ kind: str = "",
119
+ project_id: str = "",
120
+ run_id: str = "",
121
+ cursor: str = "",
122
+ limit: int = 100,
123
+ ) -> JSONObject:
124
+ """See ``Client.list_documents``."""
125
+
126
+ def query(
127
+ self,
128
+ stream_id: str,
129
+ *,
130
+ start: str,
131
+ end: str,
132
+ limit: int = 1000,
133
+ order: str = "",
134
+ after_epoch: str = "",
135
+ after_sequence: str = "",
136
+ ) -> JSONObject:
137
+ """See ``Client.query``."""
138
+
139
+ def get_command(self, command_id: str) -> JSONObject:
140
+ """See ``Client.get_command``."""
141
+
142
+ def begin(self, command: JSONObject) -> JSONObject:
143
+ """See ``Client.begin``."""
144
+
145
+ def report(self, command: JSONObject, result: JSONObject) -> JSONObject:
146
+ """See ``Client.report``."""
147
+
148
+ def renew(self, command: JSONObject) -> None:
149
+ """See ``Client.renew``."""
150
+
151
+ def ingest(self, batch: TelemetryBatch) -> JSONObject:
152
+ """See ``Client.ingest``."""
153
+
154
+ def abort(self) -> None:
155
+ """See ``Client.abort``."""
156
+
157
+
158
+ class SessionClient(RuntimeClient, Protocol):
159
+ """Subset of ``Client`` required by ``init`` and ``Run``.
160
+
161
+ Contract: adds the document administration calls a session uses beyond
162
+ the runtime surface. Capability-dependent calls (``create_key``,
163
+ ``upload_file``, ``download_artifact``) stay optional and are probed
164
+ with ``getattr``.
165
+
166
+ Failure: methods raise ``LaunchHelmError`` using the same codes as
167
+ ``Client``.
168
+ """
169
+
170
+ def update_document(self, document: Document, spec: JSONObject) -> Document:
171
+ """See ``Client.update_document``."""
172
+
173
+ def close(self) -> None:
174
+ """See ``Client.close``."""
175
+
176
+
177
+ class Client:
178
+ """Authenticated LaunchHelm HTTP client.
179
+
180
+ Contract: every JSON request body is ``encode``d unless the caller passes
181
+ already-encoded bytes (telemetry ingest). The process must not put
182
+ credentials in ``base_url``.
183
+
184
+ Failure: see module docstring.
185
+ """
186
+
187
+ def __init__(
188
+ self,
189
+ base_url: str,
190
+ api_key: str,
191
+ organization_id: str,
192
+ *,
193
+ transport: httpx.BaseTransport | None = None,
194
+ timeout: float = 30,
195
+ ) -> None:
196
+ parsed = urlparse(base_url)
197
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
198
+ raise ValueError("base URL must not contain credentials, query, or fragment")
199
+ if parsed.scheme != "https" and not (
200
+ parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
201
+ ):
202
+ raise ValueError("HTTPS is required outside loopback")
203
+ uuid.UUID(organization_id)
204
+ self.base_url = base_url.rstrip("/")
205
+ self.organization_id = organization_id
206
+ self._api_key = api_key
207
+ self._timeout = timeout
208
+ self._transport: httpx.BaseTransport = (
209
+ transport if transport is not None else AbortableTransport()
210
+ )
211
+ self.http = httpx.Client(
212
+ base_url=self.base_url,
213
+ timeout=timeout,
214
+ follow_redirects=False,
215
+ transport=self._transport,
216
+ headers={
217
+ "Authorization": f"Bearer {api_key}",
218
+ "X-LaunchHelm-Protocol": "1.0",
219
+ "User-Agent": f"launchhelm-python/{__version__}",
220
+ },
221
+ )
222
+ self._prefix = f"/v1/organizations/{organization_id}"
223
+
224
+ def close(self) -> None:
225
+ """Close the underlying HTTP pool.
226
+
227
+ Contract: idle connections are released; further requests raise from
228
+ httpx. A request blocked in another thread is not interrupted; use
229
+ ``abort`` for that.
230
+
231
+ Failure: none.
232
+ """
233
+ self.http.close()
234
+
235
+ def abort(self) -> None:
236
+ """Fail in-flight requests immediately, then close the pool.
237
+
238
+ Contract: with the default transport, a parked long-poll claim in
239
+ another thread raises ``LaunchHelmError("transport")`` within
240
+ milliseconds; ``Runtime.close`` relies on this so shutdown does not
241
+ wait out the claim. A caller-supplied transport that lacks ``abort``
242
+ is only closed.
243
+
244
+ Failure: none.
245
+ """
246
+ abort = getattr(self._transport, "abort", None)
247
+ if callable(abort):
248
+ abort()
249
+ self.close()
250
+
251
+ def __enter__(self) -> Client:
252
+ """Return ``self`` for ``with Client(...)`` usage.
253
+
254
+ Contract: ``__exit__`` closes the pool.
255
+
256
+ Failure: none.
257
+ """
258
+ return self
259
+
260
+ def __exit__(self, *args: object) -> None:
261
+ """Close the client, ignoring the block exception.
262
+
263
+ Contract: does not suppress exceptions from the ``with`` body.
264
+
265
+ Failure: none.
266
+ """
267
+ self.close()
268
+
269
+ def request(
270
+ self,
271
+ method: str,
272
+ path: str,
273
+ data: JSONValue | bytes | None = None,
274
+ *,
275
+ idempotency_key: str | None = None,
276
+ params: dict[str, JSONValue] | None = None,
277
+ timeout: float | None = None,
278
+ bearer: str | None = None,
279
+ ) -> JSONValue:
280
+ """Send one HTTP request and parse a JSON response.
281
+
282
+ Contract: ``data`` bytes are sent unchanged; other values are passed
283
+ through ``encode``. Problem-document errors become ``LaunchHelmError``.
284
+ ``timeout`` overrides the client default for this request only.
285
+ ``bearer`` replaces the client API key on this request.
286
+
287
+ Failure: ``ValueError`` for a non-relative path. ``LaunchHelmError``
288
+ for transport, oversize responses, invalid JSON, or HTTP errors.
289
+ """
290
+ if not path.startswith("/") or path.startswith("//"):
291
+ raise ValueError("API path must be relative to the configured server")
292
+ headers = {"Content-Type": "application/json"}
293
+ if bearer is not None:
294
+ headers["Authorization"] = f"Bearer {bearer}"
295
+ if idempotency_key:
296
+ headers["Idempotency-Key"] = idempotency_key
297
+ content = data if isinstance(data, bytes) else (encode(data) if data is not None else None)
298
+ query = _query_params(params)
299
+ stream_timeout = timeout if timeout is not None else self.http.timeout
300
+ try:
301
+ with self.http.stream(
302
+ method,
303
+ path,
304
+ content=content,
305
+ headers=headers,
306
+ params=query,
307
+ timeout=stream_timeout,
308
+ ) as response:
309
+ chunks = bytearray()
310
+ for chunk in response.iter_bytes():
311
+ chunks.extend(chunk)
312
+ if len(chunks) > MAX_RESPONSE_BYTES:
313
+ raise LaunchHelmError("response_too_large", "Use a smaller page")
314
+ try:
315
+ parsed: object = json.loads(chunks)
316
+ except (ValueError, UnicodeError):
317
+ if response.status_code >= 300:
318
+ # A proxy or load balancer answered (for example a 502
319
+ # page while the API restarts). Classify by status so
320
+ # a deploy is retried like any other 5xx.
321
+ raise LaunchHelmError(
322
+ "http_error",
323
+ f"HTTP {response.status_code} without a problem document",
324
+ status=response.status_code,
325
+ retryable=response.status_code >= 500 or response.status_code == 429,
326
+ ) from None
327
+ raise LaunchHelmError(
328
+ "invalid_response",
329
+ "Server returned invalid JSON",
330
+ status=response.status_code,
331
+ ) from None
332
+ result = _json_value(parsed)
333
+ if response.status_code >= 300:
334
+ problem = result.get("error") if isinstance(result, dict) else None
335
+ details = problem if isinstance(problem, dict) else {}
336
+ code = details.get("code")
337
+ message = details.get("message")
338
+ retryable_flag = details.get("retryable", False)
339
+ raise LaunchHelmError(
340
+ code if isinstance(code, str) else "http_error",
341
+ message if isinstance(message, str) else "Request failed",
342
+ status=response.status_code,
343
+ retryable=(retryable_flag if isinstance(retryable_flag, bool) else False)
344
+ or response.status_code >= 500,
345
+ )
346
+ return result
347
+ except httpx.HTTPError:
348
+ raise LaunchHelmError(
349
+ "transport", "Request delivery or outcome is uncertain", retryable=True
350
+ ) from None
351
+
352
+ def create_document(self, kind: str, scope: Scope, spec: JSONObject) -> Document:
353
+ """Create a resource in ``scope``.
354
+
355
+ Contract: ``scope.organization_id`` must match this client.
356
+
357
+ Failure: ``ValueError`` on organization mismatch. ``LaunchHelmError``
358
+ from the server.
359
+ """
360
+ if scope.organization_id != self.organization_id:
361
+ raise ValueError("organization scope mismatch")
362
+ return Document.parse(
363
+ json_object(
364
+ self.request(
365
+ "POST",
366
+ self._prefix + "/documents",
367
+ {
368
+ "kind": kind,
369
+ "scope": string_object(scope.wire()),
370
+ "spec": spec,
371
+ },
372
+ ),
373
+ what="document",
374
+ )
375
+ )
376
+
377
+ def create_documents(self, documents: list[tuple[str, Scope, JSONObject]]) -> list[Document]:
378
+ """Create every document in one request.
379
+
380
+ Contract: the server commits the batch together. ``documents`` holds
381
+ one to 1024 ``(kind, scope, spec)`` triples. Every scope uses this
382
+ client's organization.
383
+
384
+ Failure: ``ValueError`` on an empty batch, a batch over 1024, or an
385
+ organization mismatch. ``LaunchHelmError`` from the server. A rejected
386
+ batch creates nothing.
387
+ """
388
+ if not documents or len(documents) > 1024:
389
+ raise ValueError("document batch must contain 1 to 1024 documents")
390
+ body: list[JSONValue] = []
391
+ for kind, scope, spec in documents:
392
+ if scope.organization_id != self.organization_id:
393
+ raise ValueError("organization scope mismatch")
394
+ body.append(
395
+ {
396
+ "kind": kind,
397
+ "scope": string_object(scope.wire()),
398
+ "spec": spec,
399
+ }
400
+ )
401
+ result = json_object(
402
+ self.request("POST", self._prefix + "/document-batches", {"documents": body}),
403
+ what="document batch",
404
+ )
405
+ opened = result.get("documents")
406
+ if not isinstance(opened, list) or len(opened) != len(documents):
407
+ raise LaunchHelmError("protocol", "document batch length does not match")
408
+ return [Document.parse(json_object(item, what="document")) for item in opened]
409
+
410
+ def create_key(self, participant: Document) -> JSONObject:
411
+ """Mint a worker key bound to ``participant`` and its run.
412
+
413
+ Contract: the key can connect, write telemetry, and read or create
414
+ documents in that run. It expires in seven days. The token is returned
415
+ once.
416
+
417
+ Failure: ``ValueError`` on organization mismatch. ``LaunchHelmError``
418
+ from the server.
419
+ """
420
+ scope = participant.scope
421
+ if scope.organization_id != self.organization_id:
422
+ raise ValueError("organization scope mismatch")
423
+ expires = (datetime.now(UTC) + timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")
424
+ return json_object(
425
+ self.request(
426
+ "POST",
427
+ self._prefix + "/keys",
428
+ {
429
+ "name": "logger",
430
+ "participant_id": participant.id,
431
+ "expires_at": expires,
432
+ "grants": [
433
+ {
434
+ "effect": "allow",
435
+ "scope": string_object(scope.wire()),
436
+ "actions": [
437
+ "participant.connect",
438
+ "telemetry.write",
439
+ "document.create",
440
+ "document.read",
441
+ "document.list",
442
+ "document.update",
443
+ "artifact.write",
444
+ "artifact.download",
445
+ "command.claim",
446
+ "command.report",
447
+ ],
448
+ "kinds": ["*"],
449
+ "resources": ["*"],
450
+ "bounds": {},
451
+ }
452
+ ],
453
+ },
454
+ ),
455
+ what="key",
456
+ )
457
+
458
+ def adopt(self, token: str) -> None:
459
+ """Use ``token`` for later requests from this client."""
460
+ self.http.headers["Authorization"] = f"Bearer {token}"
461
+
462
+ def with_key(self) -> Client:
463
+ """Return a client on this origin using the construction credential.
464
+
465
+ Contract: the credential this client was built with is reused — not a
466
+ token later installed by ``adopt``. The derived client owns its own
467
+ connection pool; the caller closes it. The credential stays in
468
+ memory only.
469
+
470
+ Failure: same as construction.
471
+ """
472
+ return Client(
473
+ self.base_url,
474
+ self._api_key,
475
+ self.organization_id,
476
+ timeout=self._timeout,
477
+ )
478
+
479
+ def end_session(
480
+ self, participant_id: str, epoch: str, *, close_run: str = "never"
481
+ ) -> JSONObject:
482
+ """End the participant's live session epoch.
483
+
484
+ Contract: ``close_run`` is ``never`` or ``when_idle``. With
485
+ ``when_idle`` the run document closes when no other participant still
486
+ holds a live session epoch. Re-ending an already finished epoch
487
+ succeeds. The response carries the finished ``session`` epoch record
488
+ and ``run_closed``.
489
+
490
+ Failure: ``ValueError`` for a malformed id or unknown policy.
491
+ ``LaunchHelmError`` ``conflict`` when ``epoch`` is not the live
492
+ epoch, ``forbidden`` for a credential not bound to the participant.
493
+ """
494
+ uuid.UUID(participant_id)
495
+ if close_run not in {"never", "when_idle"}:
496
+ raise ValueError("close_run must be never or when_idle")
497
+ return json_object(
498
+ self.request(
499
+ "POST",
500
+ self._prefix + f"/participants/{participant_id}/session/end",
501
+ {"epoch": epoch, "close_run": close_run},
502
+ ),
503
+ what="session end",
504
+ )
505
+
506
+ def get_document(self, document_id: str, *, revision: str = "") -> Document:
507
+ """Fetch a resource by id, optionally at a historical revision.
508
+
509
+ Contract: ``document_id`` must be a UUID.
510
+
511
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
512
+ the server.
513
+ """
514
+ uuid.UUID(document_id)
515
+ return Document.parse(
516
+ json_object(
517
+ self.request(
518
+ "GET",
519
+ self._prefix + f"/documents/{document_id}",
520
+ params={"revision": revision} if revision else None,
521
+ ),
522
+ what="document",
523
+ )
524
+ )
525
+
526
+ def update_document(self, document: Document, spec: JSONObject) -> Document:
527
+ """Patch ``document`` expecting its current revision.
528
+
529
+ Contract: uses ``expected_revision`` for optimistic concurrency.
530
+
531
+ Failure: ``LaunchHelmError`` with ``conflict`` if the revision moved.
532
+ """
533
+ return Document.parse(
534
+ json_object(
535
+ self.request(
536
+ "PATCH",
537
+ self._prefix + f"/documents/{document.id}",
538
+ {"expected_revision": document.revision, "spec": spec},
539
+ ),
540
+ what="document",
541
+ )
542
+ )
543
+
544
+ def list_documents(
545
+ self,
546
+ *,
547
+ kind: str = "",
548
+ project_id: str = "",
549
+ run_id: str = "",
550
+ cursor: str = "",
551
+ limit: int = 100,
552
+ ) -> JSONObject:
553
+ """List resources with the given filters.
554
+
555
+ Contract: empty filters are sent as empty strings; the server treats
556
+ them as unconstrained.
557
+
558
+ Failure: ``LaunchHelmError`` from the server.
559
+ """
560
+ return json_object(
561
+ self.request(
562
+ "GET",
563
+ self._prefix + "/documents",
564
+ params={
565
+ "kind": kind,
566
+ "project_id": project_id,
567
+ "run_id": run_id,
568
+ "cursor": cursor,
569
+ "limit": limit,
570
+ },
571
+ ),
572
+ what="document list",
573
+ )
574
+
575
+ def submit_command(self, request: JSONObject, *, idempotency_key: str) -> JSONObject:
576
+ """Submit a command with a caller-chosen idempotency key.
577
+
578
+ Contract: repeating the same key and fingerprint returns the original
579
+ command; a different fingerprint with the same key is a conflict.
580
+
581
+ Failure: ``LaunchHelmError`` from the server.
582
+ """
583
+ return json_object(
584
+ self.request(
585
+ "POST",
586
+ self._prefix + "/commands",
587
+ request,
588
+ idempotency_key=idempotency_key,
589
+ ),
590
+ what="command",
591
+ )
592
+
593
+ def get_command(self, command_id: str) -> JSONObject:
594
+ """Fetch a command by id.
595
+
596
+ Contract: ``command_id`` must be a UUID.
597
+
598
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
599
+ the server.
600
+ """
601
+ uuid.UUID(command_id)
602
+ return json_object(
603
+ self.request("GET", self._prefix + f"/commands/{command_id}"),
604
+ what="command",
605
+ )
606
+
607
+ def decide_command(self, command: JSONObject, action: str) -> JSONObject:
608
+ """Approve or cancel ``command`` using its fingerprint.
609
+
610
+ Contract: ``action`` is ``approve`` or ``cancel``.
611
+
612
+ Failure: ``ValueError`` for an unknown action. ``LaunchHelmError``
613
+ from the server.
614
+ """
615
+ if action not in {"approve", "cancel"}:
616
+ raise ValueError("invalid command decision")
617
+ return json_object(
618
+ self.request(
619
+ "POST",
620
+ self._prefix + f"/commands/{command['id']}/{action}",
621
+ {"fingerprint": command["fingerprint"]},
622
+ ),
623
+ what="command",
624
+ )
625
+
626
+ def events(self, *, cursor: str = "0", limit: int = 100) -> JSONObject:
627
+ """Read a page of organization events.
628
+
629
+ Contract: ``cursor`` ``0`` starts at the beginning of the log.
630
+
631
+ Failure: ``LaunchHelmError`` from the server.
632
+ """
633
+ return json_object(
634
+ self.request(
635
+ "GET",
636
+ self._prefix + "/events",
637
+ params={"cursor": cursor, "limit": limit},
638
+ ),
639
+ what="events",
640
+ )
641
+
642
+ def claim_deliveries(
643
+ self,
644
+ subscription_id: str,
645
+ input_: DeliveryClaimInput | None = None,
646
+ ) -> DeliveryPage:
647
+ """Lease one ordered delivery batch from an event subscription.
648
+
649
+ Contract: unacknowledged deliveries are at-least-once. The returned
650
+ lease id and cursor must be passed to ``ack_deliveries`` after durable
651
+ consumer handling.
652
+
653
+ Failure: ``ValueError`` for a malformed id. ``LaunchHelmError`` from
654
+ the server.
655
+ """
656
+ uuid.UUID(subscription_id)
657
+ return DeliveryPage.parse(
658
+ json_object(
659
+ self.request(
660
+ "POST",
661
+ self._prefix + f"/event-subscriptions/{subscription_id}/claim",
662
+ (input_ or DeliveryClaimInput()).wire(),
663
+ ),
664
+ what="delivery_page",
665
+ )
666
+ )
667
+
668
+ def ack_deliveries(
669
+ self,
670
+ subscription_id: str,
671
+ input_: AckInput,
672
+ ) -> AckResult:
673
+ """Cumulatively acknowledge deliveries held by one lease."""
674
+ uuid.UUID(subscription_id)
675
+ return AckResult.parse(
676
+ json_object(
677
+ self.request(
678
+ "POST",
679
+ self._prefix + f"/event-subscriptions/{subscription_id}/ack",
680
+ input_.wire(),
681
+ ),
682
+ what="ack_result",
683
+ )
684
+ )
685
+
686
+ def dispatch_notification(self, input_: NotificationInput) -> NotificationDispatchResult:
687
+ """Queue, deduplicate, or preference-suppress one notification."""
688
+ return NotificationDispatchResult.parse(
689
+ json_object(
690
+ self.request("POST", self._prefix + "/notifications", input_.wire()),
691
+ what="notification_dispatch_result",
692
+ )
693
+ )
694
+
695
+ def list_notifications(
696
+ self,
697
+ *,
698
+ account_id: str = "",
699
+ state: str = "",
700
+ cursor: str = "",
701
+ limit: int = 50,
702
+ ) -> JSONObject:
703
+ """Return one inbox page, optionally filtered by account and state."""
704
+ return json_object(
705
+ self.request(
706
+ "GET",
707
+ self._prefix + "/notifications",
708
+ params={
709
+ "account_id": account_id,
710
+ "state": state,
711
+ "cursor": cursor,
712
+ "limit": limit,
713
+ },
714
+ ),
715
+ what="notification_page",
716
+ )
717
+
718
+ def read_notification(
719
+ self,
720
+ notification_id: str,
721
+ input_: NotificationReadInput | None = None,
722
+ ) -> Document:
723
+ """Mark a notification read, optionally guarded by revision."""
724
+ uuid.UUID(notification_id)
725
+ return Document.parse(
726
+ json_object(
727
+ self.request(
728
+ "POST",
729
+ self._prefix + f"/notifications/{notification_id}/read",
730
+ (input_ or NotificationReadInput()).wire(),
731
+ ),
732
+ what="document",
733
+ )
734
+ )
735
+
736
+ def publish_public_view(self, input_: PublicViewInput) -> PublicViewPublication:
737
+ """Preview or store a public view.
738
+
739
+ Contract: ``confirm`` false returns the projection and writes nothing.
740
+ ``input_.scope.organization_id`` must match this client.
741
+
742
+ Failure: ``ValueError`` on organization mismatch. ``LaunchHelmError``
743
+ from the server.
744
+ """
745
+ if input_.scope.organization_id != self.organization_id:
746
+ raise ValueError("organization scope mismatch")
747
+ return PublicViewPublication.parse(
748
+ json_object(
749
+ self.request("POST", self._prefix + "/public-views", input_.wire()),
750
+ what="public_view_publication",
751
+ )
752
+ )
753
+
754
+ def update_public_view(
755
+ self, document_id: str, input_: PublicViewUpdate
756
+ ) -> PublicViewPublication:
757
+ """Revise a public view.
758
+
759
+ Contract: ``document_id`` is a UUID. Snapshot views cannot gain
760
+ resources, paths, or surfaces.
761
+
762
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
763
+ the server.
764
+ """
765
+ uuid.UUID(document_id)
766
+ return PublicViewPublication.parse(
767
+ json_object(
768
+ self.request(
769
+ "PATCH",
770
+ self._prefix + f"/public-views/{document_id}",
771
+ input_.wire(),
772
+ ),
773
+ what="public_view_publication",
774
+ )
775
+ )
776
+
777
+ def mint_public_view_token(self, document_id: str) -> PublicViewToken:
778
+ """Mint a one-time public-view bearer token.
779
+
780
+ Contract: the returned token is the only copy. ``document_id`` is a UUID.
781
+
782
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
783
+ the server.
784
+ """
785
+ uuid.UUID(document_id)
786
+ return PublicViewToken.parse(
787
+ json_object(
788
+ self.request("POST", self._prefix + f"/public-views/{document_id}/tokens"),
789
+ what="public_view_token",
790
+ )
791
+ )
792
+
793
+ def revoke_public_view(self, document_id: str) -> Document:
794
+ """Disable a public view and revoke its live tokens.
795
+
796
+ Contract: ``document_id`` is a UUID. A second call is idempotent.
797
+
798
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
799
+ the server.
800
+ """
801
+ uuid.UUID(document_id)
802
+ return Document.parse(
803
+ json_object(
804
+ self.request("POST", self._prefix + f"/public-views/{document_id}/revoke"),
805
+ what="document",
806
+ )
807
+ )
808
+
809
+ def list_public_documents(self, token: str) -> PublicDocumentPage:
810
+ """List the documents a public token may read.
811
+
812
+ Contract: ``token`` is sent as the bearer credential for this call
813
+ only. The client's API key is not used.
814
+
815
+ Failure: ``ValueError`` when ``token`` is empty. ``LaunchHelmError``
816
+ from the server.
817
+ """
818
+ self._require_public_token(token)
819
+ return PublicDocumentPage.parse(
820
+ json_object(
821
+ self.request("GET", "/v1/public/documents", bearer=token),
822
+ what="public_document_page",
823
+ )
824
+ )
825
+
826
+ def read_public_document(self, token: str, document_id: str) -> PublicDocument:
827
+ """Read one published document.
828
+
829
+ Contract: ``token`` authenticates this call. ``document_id`` is a UUID.
830
+
831
+ Failure: ``ValueError`` for an empty token or a non-UUID id.
832
+ ``LaunchHelmError`` from the server.
833
+ """
834
+ self._require_public_token(token)
835
+ uuid.UUID(document_id)
836
+ return PublicDocument.parse(
837
+ json_object(
838
+ self.request("GET", f"/v1/public/documents/{document_id}", bearer=token),
839
+ what="public_document",
840
+ )
841
+ )
842
+
843
+ def run_public_query(self, token: str, query: Query) -> QueryResult:
844
+ """Evaluate a query inside the public view's published series.
845
+
846
+ Contract: ``token`` authenticates this call. Select sources are bound
847
+ to the view's organization by the server.
848
+
849
+ Failure: ``ValueError`` when ``token`` is empty. ``LaunchHelmError``
850
+ from the server.
851
+ """
852
+ self._require_public_token(token)
853
+ return QueryResult.parse(
854
+ json_object(
855
+ self.request("POST", "/v1/public/queries", query.wire(), bearer=token),
856
+ what="query_result",
857
+ )
858
+ )
859
+
860
+ def read_public_artifact(self, token: str, document_id: str) -> PublicDocument:
861
+ """Read one published artifact document.
862
+
863
+ Contract: ``token`` authenticates this call. ``document_id`` is a UUID.
864
+
865
+ Failure: ``ValueError`` for an empty token or a non-UUID id.
866
+ ``LaunchHelmError`` from the server.
867
+ """
868
+ self._require_public_token(token)
869
+ uuid.UUID(document_id)
870
+ return PublicDocument.parse(
871
+ json_object(
872
+ self.request("GET", f"/v1/public/artifacts/{document_id}", bearer=token),
873
+ what="public_document",
874
+ )
875
+ )
876
+
877
+ def download_artifact(self, document_id: str) -> bytes:
878
+ """Download an artifact's bytes.
879
+
880
+ Contract: ``GET .../download`` returns a URL. This method follows it
881
+ and returns the body.
882
+
883
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` when
884
+ the download document has no URL, the signed target is insecure, or
885
+ the fetch fails.
886
+ """
887
+ uuid.UUID(document_id)
888
+ body = json_object(
889
+ self.request("GET", self._prefix + f"/artifacts/{document_id}/download"),
890
+ what="download",
891
+ )
892
+ url = body.get("url")
893
+ if not isinstance(url, str) or not url:
894
+ raise LaunchHelmError("protocol", "artifact download has no url")
895
+ _check_download_url(url)
896
+ try:
897
+ # The signed object request must not inherit API auth or cookies.
898
+ response = self.http.send(httpx.Request("GET", url), auth=None, follow_redirects=False)
899
+ response.raise_for_status()
900
+ except httpx.HTTPError as error:
901
+ raise LaunchHelmError("transport", "artifact download failed") from error
902
+ return response.content
903
+
904
+ def download_public_artifact(self, token: str, document_id: str) -> str:
905
+ """Return a short-lived download URL for a published ready artifact.
906
+
907
+ Contract: ``token`` authenticates this call. ``document_id`` is a UUID.
908
+
909
+ Failure: ``ValueError`` for an empty token or a non-UUID id.
910
+ ``LaunchHelmError`` when the server omits a URL or rejects the call.
911
+ """
912
+ self._require_public_token(token)
913
+ uuid.UUID(document_id)
914
+ signed = json_object(
915
+ self.request(
916
+ "GET",
917
+ f"/v1/public/artifacts/{document_id}/download",
918
+ bearer=token,
919
+ ),
920
+ what="url",
921
+ )
922
+ url = signed.get("url")
923
+ if not isinstance(url, str) or url == "":
924
+ raise LaunchHelmError("invalid_response", "Download response is missing a URL")
925
+ return url
926
+
927
+ def create_invitation(self, input_: InvitationInput) -> Document:
928
+ """Create a pending invitation for one verified email address."""
929
+ return Document.parse(
930
+ json_object(
931
+ self.request("POST", self._prefix + "/invitations", input_.wire()),
932
+ what="document",
933
+ )
934
+ )
935
+
936
+ def list_invitations(self, *, cursor: str = "", limit: int = 50) -> JSONObject:
937
+ """Return one page of organization invitations."""
938
+ return json_object(
939
+ self.request(
940
+ "GET",
941
+ self._prefix + "/invitations",
942
+ params={"cursor": cursor, "limit": limit},
943
+ ),
944
+ what="invitation_page",
945
+ )
946
+
947
+ def resend_invitation(self, document_id: str, input_: InvitationResendInput) -> Document:
948
+ """Record another send and extend a pending invitation."""
949
+ uuid.UUID(document_id)
950
+ return Document.parse(
951
+ json_object(
952
+ self.request(
953
+ "POST",
954
+ self._prefix + f"/invitations/{document_id}/resend",
955
+ input_.wire(),
956
+ ),
957
+ what="document",
958
+ )
959
+ )
960
+
961
+ def revoke_invitation(self, document_id: str) -> Document:
962
+ """Revoke a pending invitation."""
963
+ uuid.UUID(document_id)
964
+ return Document.parse(
965
+ json_object(
966
+ self.request("POST", self._prefix + f"/invitations/{document_id}/revoke", {}),
967
+ what="document",
968
+ )
969
+ )
970
+
971
+ def accept_invitation(self, document_id: str) -> Document:
972
+ """Accept an invitation addressed to this account's verified email."""
973
+ uuid.UUID(document_id)
974
+ return Document.parse(
975
+ json_object(
976
+ self.request("POST", self._prefix + f"/invitations/{document_id}/accept", {}),
977
+ what="document",
978
+ )
979
+ )
980
+
981
+ def decline_invitation(self, document_id: str) -> Document:
982
+ """Decline an invitation addressed to this account's verified email."""
983
+ uuid.UUID(document_id)
984
+ return Document.parse(
985
+ json_object(
986
+ self.request("POST", self._prefix + f"/invitations/{document_id}/decline", {}),
987
+ what="document",
988
+ )
989
+ )
990
+
991
+ def list_my_invitations(self) -> JSONObject:
992
+ """Return pending invitations addressed to the caller's verified email."""
993
+ return json_object(self.request("GET", "/v1/invitations"), what="invitation_inbox")
994
+
995
+ def dispatch_analysis(self, input_: AnalysisRunInput, *, idempotency_key: str) -> Document:
996
+ """Queue one analysis run. The same key and input return the original run."""
997
+ return Document.parse(
998
+ json_object(
999
+ self.request(
1000
+ "POST",
1001
+ self._prefix + "/analysis-runs",
1002
+ input_.wire(),
1003
+ idempotency_key=idempotency_key,
1004
+ ),
1005
+ what="document",
1006
+ )
1007
+ )
1008
+
1009
+ def claim_analysis(self, document_id: str, input_: AnalysisClaimInput) -> Document:
1010
+ """Lease a queued analysis run for this connected worker."""
1011
+ uuid.UUID(document_id)
1012
+ return Document.parse(
1013
+ json_object(
1014
+ self.request(
1015
+ "POST",
1016
+ self._prefix + f"/analysis-runs/{document_id}/claim",
1017
+ input_.wire(),
1018
+ ),
1019
+ what="document",
1020
+ )
1021
+ )
1022
+
1023
+ def start_analysis(self, document_id: str, input_: AnalysisLeaseInput) -> Document:
1024
+ """Move a leased analysis run from dispatched to running."""
1025
+ uuid.UUID(document_id)
1026
+ return Document.parse(
1027
+ json_object(
1028
+ self.request(
1029
+ "POST",
1030
+ self._prefix + f"/analysis-runs/{document_id}/start",
1031
+ input_.wire(),
1032
+ ),
1033
+ what="document",
1034
+ )
1035
+ )
1036
+
1037
+ def report_analysis(self, document_id: str, input_: AnalysisReport) -> Document:
1038
+ """Record a terminal result for the analysis run this worker holds."""
1039
+ uuid.UUID(document_id)
1040
+ return Document.parse(
1041
+ json_object(
1042
+ self.request(
1043
+ "POST",
1044
+ self._prefix + f"/analysis-runs/{document_id}/report",
1045
+ input_.wire(),
1046
+ ),
1047
+ what="document",
1048
+ )
1049
+ )
1050
+
1051
+ def cancel_analysis(self, document_id: str) -> Document:
1052
+ """Cancel a live analysis run and release its unacked work item."""
1053
+ uuid.UUID(document_id)
1054
+ return Document.parse(
1055
+ json_object(
1056
+ self.request("POST", self._prefix + f"/analysis-runs/{document_id}/cancel", {}),
1057
+ what="document",
1058
+ )
1059
+ )
1060
+
1061
+ def record_usage(self, input_: UsageInput) -> Document:
1062
+ """Record one usage measurement. The idempotency key is a body field."""
1063
+ return Document.parse(
1064
+ json_object(
1065
+ self.request("POST", self._prefix + "/usage", input_.wire()),
1066
+ what="document",
1067
+ )
1068
+ )
1069
+
1070
+ def report_usage(self, *, start: str = "", end: str = "") -> UsageReport:
1071
+ """Sum the usage ledger over an optional half-open window."""
1072
+ params: dict[str, JSONValue] = {}
1073
+ if start:
1074
+ params["from"] = start
1075
+ if end:
1076
+ params["to"] = end
1077
+ return UsageReport.parse(
1078
+ json_object(
1079
+ self.request("GET", self._prefix + "/usage", params=params or None),
1080
+ what="usage_report",
1081
+ )
1082
+ )
1083
+
1084
+ def create_export(self, input_: ExportInput, *, idempotency_key: str) -> Document:
1085
+ """Queue one pinned export. The same key and input return the original export."""
1086
+ return Document.parse(
1087
+ json_object(
1088
+ self.request(
1089
+ "POST",
1090
+ self._prefix + "/exports",
1091
+ input_.wire(),
1092
+ idempotency_key=idempotency_key,
1093
+ ),
1094
+ what="document",
1095
+ )
1096
+ )
1097
+
1098
+ def cancel_export(self, document_id: str) -> Document:
1099
+ """Stop a queued or running export before its artifact is published."""
1100
+ uuid.UUID(document_id)
1101
+ return Document.parse(
1102
+ json_object(
1103
+ self.request("POST", self._prefix + f"/exports/{document_id}/cancel"),
1104
+ what="document",
1105
+ )
1106
+ )
1107
+
1108
+ def plan_retention(self, policy_id: str) -> RetentionPlan:
1109
+ """Dry-run one retention policy without hiding documents."""
1110
+ uuid.UUID(policy_id)
1111
+ return RetentionPlan.parse(
1112
+ json_object(
1113
+ self.request(
1114
+ "POST",
1115
+ self._prefix + f"/retention-policies/{policy_id}/plan",
1116
+ ),
1117
+ what="retention_plan",
1118
+ )
1119
+ )
1120
+
1121
+ def create_retention_hold(self, input_: RetentionHoldInput) -> Document:
1122
+ """Pin one document so retention will not hide it."""
1123
+ return Document.parse(
1124
+ json_object(
1125
+ self.request("POST", self._prefix + "/retention-holds", input_.wire()),
1126
+ what="document",
1127
+ )
1128
+ )
1129
+
1130
+ def release_retention_hold(self, document_id: str) -> Document:
1131
+ """Release a hold so the pinned document can be deleted again."""
1132
+ uuid.UUID(document_id)
1133
+ return Document.parse(
1134
+ json_object(
1135
+ self.request(
1136
+ "POST",
1137
+ self._prefix + f"/retention-holds/{document_id}/release",
1138
+ ),
1139
+ what="document",
1140
+ )
1141
+ )
1142
+
1143
+ def restore_retention(self, input_: RetentionRestoreInput) -> Document:
1144
+ """Return a hidden document to the catalog before its bytes are purged."""
1145
+ return Document.parse(
1146
+ json_object(
1147
+ self.request("POST", self._prefix + "/retention-restores", input_.wire()),
1148
+ what="document",
1149
+ )
1150
+ )
1151
+
1152
+ def _require_public_token(self, token: str) -> None:
1153
+ if token == "":
1154
+ raise ValueError("public view token is required")
1155
+
1156
+ def query(
1157
+ self,
1158
+ stream_id: str,
1159
+ *,
1160
+ start: str,
1161
+ end: str,
1162
+ limit: int = 1000,
1163
+ order: str = "",
1164
+ after_epoch: str = "",
1165
+ after_sequence: str = "",
1166
+ ) -> JSONObject:
1167
+ """Query telemetry points in ``[start, end]``.
1168
+
1169
+ Contract: ``stream_id`` must be a UUID. ``order="desc"`` returns the
1170
+ newest points first. ``after_epoch`` and ``after_sequence`` together
1171
+ skip rows at ``start`` that are not strictly after that point, so a
1172
+ page whose points share one timestamp can continue. This is not an
1173
+ analytical query engine; the server enforces bounded pages.
1174
+
1175
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
1176
+ the server.
1177
+ """
1178
+ uuid.UUID(stream_id)
1179
+ params: dict[str, JSONValue] = {"from": start, "to": end, "limit": limit}
1180
+ if order:
1181
+ params["order"] = order
1182
+ if after_epoch and after_sequence:
1183
+ params["after_epoch"] = after_epoch
1184
+ params["after_sequence"] = after_sequence
1185
+ return json_object(
1186
+ self.request(
1187
+ "GET",
1188
+ self._prefix + f"/streams/{stream_id}/points",
1189
+ params=params,
1190
+ ),
1191
+ what="points",
1192
+ )
1193
+
1194
+ def read_samples(
1195
+ self,
1196
+ stream_id: str,
1197
+ *,
1198
+ axis: JSONObject | None = None,
1199
+ select: JSONObject,
1200
+ ) -> JSONObject:
1201
+ """Read bounded samples of one stream for a rich renderer.
1202
+
1203
+ Contract: mirrors ``POST /telemetry/samples`` — ``axis`` is an
1204
+ ``axis_spec`` object (defaults to the ``step`` coordinate axis)
1205
+ and ``select`` a ``samples_select`` object such as
1206
+ ``{"mode": "latest", "limit": 16}``, ``{"mode": "at",
1207
+ "values": ["10"]}``, ``{"mode": "spread", "limit": 64,
1208
+ "window": {...}}``, or ``{"mode": "index", "limit": 256}``.
1209
+ Returns the ``samples_result`` object: ``points`` carry ``x`` and
1210
+ the stored ``value`` (omitted for ``index``), ``complete`` is
1211
+ false when the response was truncated, and ``media`` maps media
1212
+ artifact ids to authorized download entries.
1213
+
1214
+ Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError``
1215
+ from the server.
1216
+ """
1217
+ uuid.UUID(stream_id)
1218
+ payload: JSONObject = {
1219
+ "stream_id": stream_id,
1220
+ "axis": axis or {"kind": "coordinate", "name": "step"},
1221
+ "select": select,
1222
+ }
1223
+ return json_object(
1224
+ self.request(
1225
+ "POST",
1226
+ self._prefix + "/telemetry/samples",
1227
+ payload,
1228
+ ),
1229
+ what="samples",
1230
+ )
1231
+
1232
+ def run_query(self, organization_id: str, query: Query) -> QueryResult:
1233
+ """Evaluate an ad-hoc telemetry query.
1234
+
1235
+ Contract: ``organization_id`` must match this client. ``query`` is
1236
+ sent as its wire document.
1237
+
1238
+ Failure: ``ValueError`` from ``uuid.UUID`` or an organization
1239
+ mismatch. ``LaunchHelmError`` from the server.
1240
+ """
1241
+ self._require_organization(organization_id)
1242
+ return QueryResult.parse(
1243
+ json_object(
1244
+ self.request(
1245
+ "POST",
1246
+ f"/v1/organizations/{organization_id}/telemetry/queries",
1247
+ query.wire(),
1248
+ ),
1249
+ what="query_result",
1250
+ )
1251
+ )
1252
+
1253
+ def run_saved_query(
1254
+ self, organization_id: str, query_id: str, *, revision: str | None = None
1255
+ ) -> QueryResult:
1256
+ """Evaluate a stored query document, optionally at ``revision``.
1257
+
1258
+ Contract: ``organization_id`` must match this client. ``query_id``
1259
+ must be a UUID. Empty ``revision`` selects the current document.
1260
+
1261
+ Failure: ``ValueError`` from ``uuid.UUID`` or an organization
1262
+ mismatch. ``LaunchHelmError`` from the server.
1263
+ """
1264
+ self._require_organization(organization_id)
1265
+ uuid.UUID(query_id)
1266
+ body: JSONObject = {}
1267
+ if revision:
1268
+ body["revision"] = revision
1269
+ return QueryResult.parse(
1270
+ json_object(
1271
+ self.request(
1272
+ "POST",
1273
+ f"/v1/organizations/{organization_id}/queries/{query_id}/run",
1274
+ body,
1275
+ ),
1276
+ what="query_result",
1277
+ )
1278
+ )
1279
+
1280
+ def list_my_organizations(self) -> list[tuple[str, str]]:
1281
+ """Return ``(id, name)`` for every organization this key can see.
1282
+
1283
+ Contract: ``GET /v1/organizations`` is account-scoped. The client's
1284
+ organization id is not part of the request. Pages follow
1285
+ ``next_cursor`` until the server ends the list.
1286
+
1287
+ Failure: ``LaunchHelmError`` from the server or a malformed page.
1288
+ """
1289
+ cursor = ""
1290
+ found: list[tuple[str, str]] = []
1291
+ for _ in range(100):
1292
+ page = json_object(
1293
+ self.request(
1294
+ "GET",
1295
+ "/v1/organizations",
1296
+ params={"cursor": cursor, "limit": 100},
1297
+ ),
1298
+ what="organization list",
1299
+ )
1300
+ items = page.get("items") or []
1301
+ if not isinstance(items, list):
1302
+ raise LaunchHelmError("protocol", "organization list is not a page")
1303
+ for item in items:
1304
+ if not isinstance(item, dict):
1305
+ raise LaunchHelmError("protocol", "organization list entry is invalid")
1306
+ identity = item.get("id")
1307
+ if not isinstance(identity, str):
1308
+ raise LaunchHelmError("protocol", "organization list entry is invalid")
1309
+ name = item.get("name")
1310
+ found.append((identity, name if isinstance(name, str) else ""))
1311
+ next_cursor = page.get("next_cursor") or ""
1312
+ if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
1313
+ break
1314
+ cursor = next_cursor
1315
+ return found
1316
+
1317
+ def _require_organization(self, organization_id: str) -> None:
1318
+ uuid.UUID(organization_id)
1319
+ if organization_id != self.organization_id:
1320
+ raise ValueError("organization scope mismatch")
1321
+
1322
+ def connect(
1323
+ self,
1324
+ participant_id: str,
1325
+ instance_id: str,
1326
+ *,
1327
+ safe_points: tuple[str, ...] = (),
1328
+ attempt_id: str = "",
1329
+ ) -> JSONObject:
1330
+ """Open an execution session for ``participant_id``.
1331
+
1332
+ Contract: ``instance_id`` identifies this process. The returned
1333
+ ``epoch`` is the session fence token. ``safe_points`` is the full set
1334
+ of cooperative names this runtime services; Claim returns only commands
1335
+ whose ``safe_point`` is empty or in this set. ``attempt_id`` binds
1336
+ emitted provenance to the active attempt. Repeating Connect with the
1337
+ same instance and attempt does not fence in-flight commands and grows
1338
+ the serviced safe-point set.
1339
+
1340
+ Failure: ``LaunchHelmError`` from the server.
1341
+ """
1342
+ body: JSONObject = {
1343
+ "instance_id": instance_id,
1344
+ "safe_points": list(safe_points),
1345
+ }
1346
+ if attempt_id:
1347
+ body["attempt_id"] = attempt_id
1348
+ return json_object(
1349
+ self.request(
1350
+ "POST",
1351
+ self._prefix + f"/participants/{participant_id}/sessions",
1352
+ body,
1353
+ ),
1354
+ what="session",
1355
+ )
1356
+
1357
+ def heartbeat(
1358
+ self,
1359
+ participant_id: str,
1360
+ epoch: str,
1361
+ *,
1362
+ safe_points: tuple[str, ...] = (),
1363
+ ) -> JSONObject:
1364
+ """Renew the execution session identified by ``epoch``.
1365
+
1366
+ Contract: a conflict means this runtime is fenced. Optional
1367
+ ``safe_points`` are unioned into the session (same validation and cap
1368
+ as Connect) without fencing. Connect still declares the initial set.
1369
+
1370
+ Failure: ``LaunchHelmError``; ``conflict`` is the fence signal.
1371
+ """
1372
+ body: JSONObject = {"epoch": epoch}
1373
+ if safe_points:
1374
+ body["safe_points"] = list(safe_points)
1375
+ return json_object(
1376
+ self.request(
1377
+ "POST",
1378
+ self._prefix + f"/participants/{participant_id}/heartbeat",
1379
+ body,
1380
+ ),
1381
+ what="session",
1382
+ )
1383
+
1384
+ def claim(self, participant_id: str, epoch: str, *, wait_ms: int = 0) -> JSONObject | None:
1385
+ """Claim the next command for this participant and epoch.
1386
+
1387
+ Contract: returns ``None`` when the queue is empty. ``wait_ms`` is a
1388
+ bounded server-side long poll (0 is immediate). The HTTP timeout is
1389
+ set above ``wait_ms`` so a parked claim is not aborted by the client.
1390
+
1391
+ Failure: ``LaunchHelmError`` from the server.
1392
+ """
1393
+ body: JSONObject = {"epoch": epoch}
1394
+ timeout: float | None = None
1395
+ if wait_ms > 0:
1396
+ body["wait_ms"] = wait_ms
1397
+ timeout = wait_ms / 1000 + 6
1398
+ payload = json_object(
1399
+ self.request(
1400
+ "POST",
1401
+ self._prefix + f"/participants/{participant_id}/commands",
1402
+ body,
1403
+ timeout=timeout,
1404
+ ),
1405
+ what="claim",
1406
+ )
1407
+ command = payload.get("command")
1408
+ if command is None:
1409
+ return None
1410
+ return json_object(command, what="command")
1411
+
1412
+ def begin(self, command: JSONObject) -> JSONObject:
1413
+ """Mark ``command`` as applying under its lease.
1414
+
1415
+ Contract: must be called after local journal phase ``starting`` and
1416
+ before the handler. A definitive rejection is not retried as success.
1417
+
1418
+ Failure: ``LaunchHelmError`` from the server.
1419
+ """
1420
+ request = json_object(command.get("request") or {}, what="command request")
1421
+ return json_object(
1422
+ self.request(
1423
+ "POST",
1424
+ self._prefix + f"/commands/{command['id']}/begin",
1425
+ {"lease_id": command["lease_id"], "epoch": request["epoch"]},
1426
+ ),
1427
+ what="command",
1428
+ )
1429
+
1430
+ def report(self, command: JSONObject, result: JSONObject) -> JSONObject:
1431
+ """Publish a command result (applied, rejected, or outcome_unknown).
1432
+
1433
+ Contract: unknown effects stay unknown; this method does not re-run
1434
+ the handler.
1435
+
1436
+ Failure: ``LaunchHelmError`` from the server. A conflict on a
1437
+ rejected result is reconciled by the runtime.
1438
+ """
1439
+ return json_object(
1440
+ self.request("POST", self._prefix + f"/commands/{command['id']}/result", result),
1441
+ what="command",
1442
+ )
1443
+
1444
+ def renew(self, command: JSONObject) -> None:
1445
+ """Renew the lease of an in-flight command.
1446
+
1447
+ Contract: a conflict means the lease is already terminal.
1448
+
1449
+ Failure: ``LaunchHelmError`` from the server.
1450
+ """
1451
+ request = json_object(command.get("request") or {}, what="command request")
1452
+ self.request(
1453
+ "POST",
1454
+ self._prefix + f"/commands/{command['id']}/heartbeat",
1455
+ {"lease_id": command["lease_id"], "epoch": request["epoch"]},
1456
+ )
1457
+
1458
+ def ingest(self, batch: TelemetryBatch) -> JSONObject:
1459
+ """Submit a durable telemetry batch.
1460
+
1461
+ Contract: sends ``batch.payload`` unchanged so the wire document is
1462
+ byte-identical to the spooled batch.
1463
+
1464
+ Failure: ``LaunchHelmError`` from the server. Durability checking is
1465
+ the runtime's responsibility.
1466
+ """
1467
+ return json_object(
1468
+ self.request("POST", self._prefix + "/telemetry", batch.payload),
1469
+ what="receipt",
1470
+ )
1471
+
1472
+ def ingest_many(self, batches: Sequence[TelemetryBatch]) -> JSONObject:
1473
+ """Submit grouped telemetry batches in one request.
1474
+
1475
+ Contract: each ``batch.payload`` is embedded unchanged so every wire
1476
+ batch document stays byte-identical to its spooled record. The
1477
+ response carries one receipt per submitted batch, keyed by
1478
+ ``batch_id``.
1479
+
1480
+ Failure: ``ValueError`` when ``batches`` is empty.
1481
+ ``LaunchHelmError`` from the server — including ``not_found`` when
1482
+ the server predates grouped ingest. Per-batch durability checking is
1483
+ the runtime's responsibility.
1484
+ """
1485
+ if not batches:
1486
+ raise ValueError("ingest_many requires at least one batch")
1487
+ payload = (
1488
+ b'{"protocol":"1.0","batches":[' + b",".join(batch.payload for batch in batches) + b"]}"
1489
+ )
1490
+ return json_object(
1491
+ self.request("POST", self._prefix + "/telemetry/batches", payload),
1492
+ what="ingestion envelope",
1493
+ )
1494
+
1495
+ def upload_file(
1496
+ self,
1497
+ path: str | Path,
1498
+ scope: Scope,
1499
+ *,
1500
+ idempotency_key: str,
1501
+ media_type: str = "application/octet-stream",
1502
+ part_size: int = 16 << 20,
1503
+ provenance: JSONObject | None = None,
1504
+ purpose: str = "general",
1505
+ ) -> Document:
1506
+ """Upload a local file as an artifact via signed multipart PUT.
1507
+
1508
+ Contract: the source file must not change during the upload (size,
1509
+ mtime, inode). Object-store URLs must be HTTPS outside loopback.
1510
+ Repeating ``idempotency_key`` resumes or returns the existing
1511
+ artifact.
1512
+
1513
+ Failure: ``ValueError`` for an impossible part budget. ``LaunchHelmError``
1514
+ for insecure URLs, transport uncertainty, a missing ETag, or a
1515
+ mutated source file.
1516
+ """
1517
+ source = Path(path)
1518
+ if part_size < MIN_PART_SIZE or part_size > MAX_PART_SIZE:
1519
+ raise ValueError("part_size must be between 5 and 64 MiB")
1520
+ with source.open("rb") as stream:
1521
+ initial = source.stat()
1522
+ size = initial.st_size
1523
+ if size < 1 or (size + part_size - 1) // part_size > MAX_PARTS:
1524
+ raise ValueError("file does not fit the configured multipart budget")
1525
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
1526
+ stream.seek(0)
1527
+ payload: JSONObject = {
1528
+ "scope": string_object(scope.wire()),
1529
+ "name": source.name,
1530
+ "media_type": media_type,
1531
+ "size": str(size),
1532
+ "sha256": digest,
1533
+ "provenance": provenance or {"inputs": [], "coordinates": [], "labels": {}},
1534
+ "purpose": purpose,
1535
+ }
1536
+ artifact = Document.parse(
1537
+ json_object(
1538
+ self.request(
1539
+ "POST",
1540
+ self._prefix + "/artifacts",
1541
+ payload,
1542
+ idempotency_key=idempotency_key,
1543
+ ),
1544
+ what="artifact",
1545
+ )
1546
+ )
1547
+ state = artifact.spec.get("state")
1548
+ if state in {"ready", "verifying"}:
1549
+ return artifact
1550
+ parts: list[JSONValue] = []
1551
+ with httpx.Client(timeout=UPLOAD_TIMEOUT_SECONDS, follow_redirects=False) as uploads:
1552
+ number = 0
1553
+ while chunk := stream.read(part_size):
1554
+ number += 1
1555
+ signed = json_object(
1556
+ self.request(
1557
+ "POST",
1558
+ self._prefix + f"/artifacts/{artifact.id}/parts",
1559
+ {
1560
+ "number": number,
1561
+ "size": str(len(chunk)),
1562
+ "sha256": hashlib.sha256(chunk).hexdigest(),
1563
+ },
1564
+ ),
1565
+ what="signed part",
1566
+ )
1567
+ url = signed.get("url")
1568
+ if not isinstance(url, str):
1569
+ raise LaunchHelmError("invalid_response", "Signed upload is missing a URL")
1570
+ target = urlparse(url)
1571
+ if target.scheme != "https" and target.hostname not in {
1572
+ "localhost",
1573
+ "127.0.0.1",
1574
+ "::1",
1575
+ }:
1576
+ raise LaunchHelmError("insecure_upload", "Object upload requires HTTPS")
1577
+ headers_field = signed.get("headers")
1578
+ upload_headers = (
1579
+ {str(k): str(v) for k, v in headers_field.items()}
1580
+ if isinstance(headers_field, dict)
1581
+ else {}
1582
+ )
1583
+ try:
1584
+ response = uploads.put(url, content=chunk, headers=upload_headers)
1585
+ except httpx.HTTPError:
1586
+ raise LaunchHelmError(
1587
+ "upload_uncertain",
1588
+ "Retry with the same artifact idempotency key",
1589
+ retryable=True,
1590
+ ) from None
1591
+ if response.status_code >= 300 or "etag" not in response.headers:
1592
+ raise LaunchHelmError(
1593
+ "upload_failed",
1594
+ "Object store did not acknowledge the part",
1595
+ retryable=True,
1596
+ )
1597
+ part: JSONObject = {
1598
+ "number": number,
1599
+ "etag": response.headers["etag"],
1600
+ }
1601
+ parts.append(part)
1602
+ final = source.stat()
1603
+ if (initial.st_size, initial.st_mtime_ns, initial.st_ino) != (
1604
+ final.st_size,
1605
+ final.st_mtime_ns,
1606
+ final.st_ino,
1607
+ ):
1608
+ raise LaunchHelmError("source_changed", "Artifact source changed during upload")
1609
+ complete: JSONObject = {"parts": parts}
1610
+ return Document.parse(
1611
+ json_object(
1612
+ self.request(
1613
+ "POST",
1614
+ self._prefix + f"/artifacts/{artifact.id}/complete",
1615
+ complete,
1616
+ ),
1617
+ what="artifact",
1618
+ )
1619
+ )
1620
+
1621
+
1622
+ def _query_params(params: dict[str, JSONValue] | None) -> dict[str, str | int] | None:
1623
+ if params is None:
1624
+ return None
1625
+ query: dict[str, str | int] = {}
1626
+ for key, value in params.items():
1627
+ if isinstance(value, bool) or value is None:
1628
+ query[key] = json.dumps(value)
1629
+ elif isinstance(value, str | int):
1630
+ query[key] = value
1631
+ else:
1632
+ query[key] = json.dumps(value)
1633
+ return query
1634
+
1635
+
1636
+ def _check_download_url(url: str) -> None:
1637
+ try:
1638
+ parsed = urlparse(url)
1639
+ host = parsed.hostname
1640
+ # Touching port validates its range and syntax.
1641
+ _ = parsed.port
1642
+ userinfo = parsed.username is not None or parsed.password is not None
1643
+ except ValueError:
1644
+ raise LaunchHelmError(
1645
+ "insecure_download", "Object download requires HTTPS without credentials"
1646
+ ) from None
1647
+ secure = parsed.scheme == "https" or (
1648
+ parsed.scheme == "http" and host in {"localhost", "127.0.0.1", "::1"}
1649
+ )
1650
+ if not secure or not host or userinfo:
1651
+ raise LaunchHelmError(
1652
+ "insecure_download", "Object download requires HTTPS without credentials"
1653
+ )
1654
+
1655
+
1656
+ def _json_value(value: object) -> JSONValue:
1657
+ try:
1658
+ return as_json_value(value)
1659
+ except TypeError:
1660
+ raise LaunchHelmError("invalid_response", "Server returned a non-JSON value") from None