stacklink-sdk 0.2.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.
stacklink/workbench.py ADDED
@@ -0,0 +1,1090 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import time
5
+ from typing import Any, Iterator, Literal, Mapping, TypedDict, cast
6
+ from urllib.error import HTTPError
7
+ from urllib.request import Request, urlopen
8
+
9
+ from .errors import StacklinkError
10
+
11
+
12
+ WorkbenchLanguage = Literal["python", "javascript", "typescript"]
13
+ WorkbenchOutputMode = Literal["inline", "preview", "events", "artifact"]
14
+ WorkbenchExecutionKind = Literal["workbench_code", "workbench_bash"]
15
+ WorkbenchReplayEnvironmentMode = Literal["require_match", "restore_snapshot", "current"]
16
+ WorkbenchExecutionStatus = Literal[
17
+ "queued",
18
+ "started",
19
+ "running",
20
+ "cancel_requested",
21
+ "completed",
22
+ "failed",
23
+ "cancelled",
24
+ "timed_out",
25
+ ]
26
+
27
+
28
+ class _WorkbenchUnset:
29
+ pass
30
+
31
+
32
+ _WORKBENCH_UNSET = _WorkbenchUnset()
33
+ _REUSABLE_RUN_COMPARISON_WAIT_SECONDS = 120.0
34
+ _REUSABLE_RUN_COMPARISON_POLL_SECONDS = 1.0
35
+
36
+
37
+ class WorkbenchReusableRunRequest(TypedDict, total=False):
38
+ mode: Literal["auto", "off"]
39
+ seriesId: str
40
+ name: str
41
+ description: str
42
+ versionReason: str
43
+
44
+
45
+ class WorkbenchSession(TypedDict, total=False):
46
+ id: str
47
+ platformSessionId: str
48
+ consumerAppId: str
49
+ tenantId: str
50
+ status: str
51
+ runtimeProvider: str | None
52
+ runtimeSessionId: str | None
53
+ sandboxSize: Literal["standard", "medium", "large"]
54
+ runtimeSandboxSize: Literal["standard", "medium", "large"] | None
55
+ runtimeGeneration: int
56
+ runtimeEnvironmentRestoredGeneration: int
57
+ activeExecutionCount: int
58
+ workspaceStorageKind: str
59
+ workspaceStorageRef: str | None
60
+ environmentManifest: dict[str, Any]
61
+ lastActiveAt: str
62
+ destroyedAt: str | None
63
+ createdAt: str
64
+ updatedAt: str
65
+
66
+
67
+ class WorkbenchSessionUseResponse(TypedDict):
68
+ workbenchSession: WorkbenchSession
69
+ reused: bool
70
+
71
+
72
+ class WorkbenchSessionDestroyResponse(TypedDict):
73
+ workbenchSession: WorkbenchSession
74
+ computeSuspended: bool
75
+ retainedFiles: bool
76
+
77
+
78
+ class WorkbenchWorkspaceDeleteReceipt(TypedDict):
79
+ deleted: Literal[True]
80
+ workspaceKey: str
81
+ deletedWorkspaceObjects: int
82
+ deletedExecutionObjects: int
83
+ deletedObjects: int
84
+
85
+
86
+ class WorkbenchDeletedDatabaseRows(TypedDict):
87
+ workbenchSessions: int
88
+ backgroundJobs: int
89
+ files: int
90
+ fileUploads: int
91
+ artifacts: int
92
+ executionLogs: int
93
+ executionEvents: int
94
+ executionEventStreams: int
95
+ runDefinitions: int
96
+ environmentSnapshots: int
97
+ usageEvents: int
98
+ costLedger: int
99
+ budgetPolicies: int
100
+ budgetReservations: int
101
+ runtimeHeartbeats: int
102
+ approvalRequests: int
103
+
104
+
105
+ class WorkbenchSessionDeleteReceipt(TypedDict):
106
+ deleted: Literal[True]
107
+ workbenchSessionId: str
108
+ platformSessionId: str
109
+ computeDestroyed: bool
110
+ deletedArtifactObjects: int
111
+ deletedUploadObjects: int
112
+ deletedSnapshots: int
113
+ workspace: WorkbenchWorkspaceDeleteReceipt
114
+ deletedDatabaseRows: WorkbenchDeletedDatabaseRows
115
+ databaseDeleted: Literal[True]
116
+
117
+
118
+ class WorkbenchRunInput(TypedDict, total=False):
119
+ language: WorkbenchLanguage
120
+ code: str
121
+ workbenchSessionId: str
122
+ timeoutMs: int
123
+ background: bool
124
+ notifyOnComplete: bool
125
+ outputMode: WorkbenchOutputMode
126
+ maxOutputBytes: int
127
+ reusableRun: WorkbenchReusableRunRequest
128
+ currentStep: str
129
+ currentStepMetric: str
130
+ thought: str
131
+ metadata: dict[str, Any]
132
+
133
+
134
+ class WorkbenchBashInput(TypedDict, total=False):
135
+ command: str
136
+ workbenchSessionId: str
137
+ timeoutMs: int
138
+ background: bool
139
+ notifyOnComplete: bool
140
+ outputMode: WorkbenchOutputMode
141
+ maxOutputBytes: int
142
+ reusableRun: WorkbenchReusableRunRequest
143
+ currentStep: str
144
+ currentStepMetric: str
145
+ thought: str
146
+ metadata: dict[str, Any]
147
+
148
+
149
+ class WorkbenchExecutionLog(TypedDict, total=False):
150
+ id: str
151
+ workbenchSessionId: str
152
+ operation: str
153
+ language: WorkbenchLanguage | None
154
+ status: WorkbenchExecutionStatus
155
+ durationMs: int | None
156
+ artifactId: str | None
157
+ timeoutMs: int | None
158
+ deadlineAt: str | None
159
+ cancelRequestedAt: str | None
160
+ cancelledAt: str | None
161
+ runtimeProvider: str | None
162
+ runtimeSessionId: str | None
163
+ runtimeProcessId: str | None
164
+ runtimeOutputCursor: str | None
165
+ approvalRequestId: str | None
166
+ idempotencyKey: str | None
167
+ runtimeStartedAt: str | None
168
+ startedAt: str
169
+ completedAt: str | None
170
+ metadata: dict[str, Any]
171
+ result: Any
172
+ reusableRun: "WorkbenchReusableRunState"
173
+
174
+
175
+ class WorkbenchObservableResultTerminal(TypedDict):
176
+ status: Literal["completed", "failed", "cancelled", "timed_out"]
177
+ exitCode: int | None
178
+
179
+
180
+ class WorkbenchObservableResultOutput(TypedDict):
181
+ orderedSha256: str
182
+ stdoutSha256: str
183
+ stderrSha256: str
184
+ structuredResultSha256: str
185
+ byteCount: int
186
+
187
+
188
+ class WorkbenchObservableResultArtifact(TypedDict):
189
+ sha256: str
190
+ sizeBytes: int
191
+ contentType: str | None
192
+
193
+
194
+ class WorkbenchObservableResultV1(TypedDict):
195
+ contractVersion: Literal["workbench.observable-result.v1"]
196
+ terminal: WorkbenchObservableResultTerminal
197
+ output: WorkbenchObservableResultOutput
198
+ artifact: WorkbenchObservableResultArtifact | None
199
+
200
+
201
+ class WorkbenchReusableRunState(TypedDict, total=False):
202
+ mode: Literal["auto", "off"]
203
+ status: Literal["pending", "ready", "failed", "skipped"]
204
+ seriesId: str | None
205
+ name: str | None
206
+ description: str | None
207
+ versionReason: str | None
208
+ errorCode: str | None
209
+ runDefinitionId: str | None
210
+ version: int | None
211
+ replayOfRunDefinitionId: str | None
212
+ observableResultContractVersion: str | None
213
+ observableResult: WorkbenchObservableResultV1 | None
214
+ observableResultSha256: str | None
215
+ comparisonStatus: Literal["matched", "mismatched"] | None
216
+ comparison: "WorkbenchRunComparison | None"
217
+ comparedAt: str | None
218
+ nextAction: Literal["get_execution", "get_reusable_run"] | None
219
+
220
+
221
+ class WorkbenchRuntimeDiagnostics(TypedDict, total=False):
222
+ code: str
223
+ message: str
224
+ runtimeStatus: str
225
+ exitCode: int
226
+ error: Any
227
+
228
+
229
+ class WorkbenchRuntimeResult(TypedDict, total=False):
230
+ status: str
231
+ success: bool
232
+ exitCode: int | None
233
+ terminalReason: str
234
+ stdout: str
235
+ stderr: str
236
+ outputBytes: int
237
+ outputTruncated: bool
238
+ diagnostics: WorkbenchRuntimeDiagnostics
239
+
240
+
241
+ class WorkbenchRuntimeIdentity(TypedDict, total=False):
242
+ provider: str
243
+ sessionId: str
244
+ workspaceKey: str
245
+
246
+
247
+ class WorkbenchRuntimeEnvelope(TypedDict):
248
+ runtime: WorkbenchRuntimeIdentity
249
+ result: WorkbenchRuntimeResult
250
+
251
+
252
+ class WorkbenchBackgroundExecutionResult(TypedDict):
253
+ executionKind: WorkbenchExecutionKind
254
+ executionId: str
255
+ status: Literal["queued", "running"]
256
+ runtimeProcessId: str | None
257
+ outputMode: Literal["events"]
258
+
259
+
260
+ class WorkbenchOutputPreview(TypedDict):
261
+ outputMode: Literal["preview"]
262
+ outputBytes: int
263
+ preview: str
264
+
265
+
266
+ class WorkbenchFile(TypedDict, total=False):
267
+ id: str
268
+ path: str
269
+ contentType: str
270
+ sizeBytes: int | str
271
+ storageKind: str
272
+ sha256: str | None
273
+ etag: str | None
274
+ contentBase64: str
275
+ contentEncoding: Literal["base64"]
276
+ createdAt: str
277
+ updatedAt: str
278
+
279
+
280
+ class WorkbenchFileResponse(TypedDict):
281
+ file: WorkbenchFile
282
+
283
+
284
+ class WorkbenchFileListResponse(TypedDict, total=False):
285
+ files: list[WorkbenchFile]
286
+ runtimeFiles: list[WorkbenchFile]
287
+
288
+
289
+ class WorkbenchFileMutationResponse(TypedDict, total=False):
290
+ file: WorkbenchFile
291
+ uploadId: str
292
+ finalized: bool
293
+ deleted: bool
294
+ path: str
295
+
296
+
297
+ class WorkbenchFileSignedUpload(TypedDict):
298
+ uploadId: str
299
+ path: str
300
+ contentType: str
301
+ sizeBytes: int
302
+ storageKind: Literal["r2_workspace"]
303
+ uploadUrl: str
304
+ expiresAt: str
305
+ requiredHeaders: dict[str, str]
306
+
307
+
308
+ class WorkbenchFileUploadPrepareResponse(TypedDict):
309
+ upload: WorkbenchFileSignedUpload
310
+
311
+
312
+ class WorkbenchFileSignedDownload(TypedDict):
313
+ file: WorkbenchFile
314
+ downloadUrl: str
315
+ expiresAt: str
316
+
317
+
318
+ class WorkbenchArtifact(TypedDict, total=False):
319
+ id: str
320
+ artifactRef: str
321
+ path: str
322
+ label: str | None
323
+ contentType: str | None
324
+ sizeBytes: int | str | None
325
+ sha256: str | None
326
+ storageKind: str
327
+ status: str
328
+ downloadUrl: str
329
+ createdAt: str
330
+ updatedAt: str
331
+
332
+
333
+ class WorkbenchArtifactResponse(TypedDict, total=False):
334
+ workbenchSession: WorkbenchSession
335
+ artifact: WorkbenchArtifact
336
+ deleted: bool
337
+
338
+
339
+ class WorkbenchArtifactListResponse(TypedDict, total=False):
340
+ workbenchSession: WorkbenchSession
341
+ artifacts: list[WorkbenchArtifact]
342
+
343
+
344
+ class WorkbenchArtifactDownloadResponse(TypedDict, total=False):
345
+ artifact: WorkbenchArtifact
346
+ downloadUrl: str
347
+ expiresAt: str
348
+ contentType: str
349
+
350
+
351
+ class WorkbenchRunComparison(TypedDict, total=False):
352
+ contractVersion: Literal["workbench.observable-result.v1"]
353
+ status: Literal["pending", "pending_finalization", "matched", "mismatched"]
354
+ sourceExecutionId: str
355
+ replayExecutionId: str
356
+ terminalMatch: bool
357
+ outputMatch: bool
358
+ artifactMatch: bool
359
+ sourceObservableResultSha256: str
360
+ replayObservableResultSha256: str
361
+ nextAction: Literal["get_reusable_run"]
362
+
363
+
364
+ class WorkbenchRunReplay(TypedDict):
365
+ executionId: str
366
+ status: WorkbenchExecutionStatus
367
+ comparison: WorkbenchRunComparison
368
+
369
+
370
+ class WorkbenchRunDefinition(TypedDict, total=False):
371
+ id: str
372
+ seriesId: str
373
+ version: int
374
+ workbenchSessionId: str
375
+ platformSessionId: str
376
+ sourceExecutionId: str
377
+ environmentSnapshotId: str | None
378
+ name: str
379
+ description: str | None
380
+ kind: Literal["code", "bash"]
381
+ language: WorkbenchLanguage | None
382
+ source: str
383
+ publicationStatus: Literal["draft", "published", "retired"]
384
+ validationStatus: Literal["unvalidated", "matched", "mismatched"]
385
+ validatedAt: str | None
386
+ validatedByExecutionId: str | None
387
+ sourceObservableResult: dict[str, Any] | None
388
+ sourceObservableResultSha256: str | None
389
+ sourceCodeSha256: str | None
390
+ environmentManifestSha256: str | None
391
+ creationMode: Literal["auto", "migrated_manual"]
392
+ versionReason: str | None
393
+ environmentManifest: dict[str, Any]
394
+ metadata: dict[str, Any]
395
+ replayCount: int
396
+ replays: list[WorkbenchRunReplay]
397
+ publishedAt: str | None
398
+ retiredAt: str | None
399
+ createdAt: str
400
+
401
+
402
+ class WorkbenchRunDefinitionResponse(TypedDict):
403
+ runDefinition: WorkbenchRunDefinition
404
+
405
+
406
+ class WorkbenchRunDefinitionListResponse(TypedDict):
407
+ runDefinitions: list[WorkbenchRunDefinition]
408
+ limit: int
409
+ hasMore: bool
410
+ nextCursor: str | None
411
+
412
+
413
+ class WorkbenchRunComparisonResponse(TypedDict):
414
+ runDefinition: WorkbenchRunDefinition
415
+ execution: "WorkbenchExecutionResponse"
416
+ environment: "WorkbenchRunEnvironmentResolution"
417
+ comparison: WorkbenchRunComparison
418
+
419
+
420
+ class WorkbenchRunEnvironmentResolution(TypedDict):
421
+ mode: WorkbenchReplayEnvironmentMode
422
+ status: Literal["queued", "completed", "failed"]
423
+ matchedBeforeReplay: bool | None
424
+ restored: bool
425
+ snapshotId: str | None
426
+
427
+
428
+ class WorkbenchRunActivationResponse(TypedDict, total=False):
429
+ action: Literal["publish", "rollback"]
430
+ runDefinition: WorkbenchRunDefinition
431
+ previousPublishedRunDefinitionId: str | None
432
+ previousPublishedVersion: int | None
433
+
434
+
435
+ class WorkbenchExecutionEvent(TypedDict, total=False):
436
+ id: str
437
+ sequence: str | int
438
+ executionKind: WorkbenchExecutionKind
439
+ executionId: str
440
+ eventType: str
441
+ status: str | None
442
+ stream: Literal["stdout", "stderr"] | None
443
+ message: str | None
444
+ data: Any
445
+ metadata: dict[str, Any]
446
+ occurredAt: str
447
+
448
+
449
+ class WorkbenchExecutionEventsPage(TypedDict, total=False):
450
+ events: list[WorkbenchExecutionEvent]
451
+ nextCursor: str | None
452
+
453
+
454
+ class WorkbenchExecutionStateResponse(TypedDict, total=False):
455
+ execution: WorkbenchExecutionLog
456
+ events: list[WorkbenchExecutionEvent]
457
+ cancelled: bool
458
+ remoteCancellation: dict[str, Any]
459
+
460
+
461
+ class WorkbenchExecutionResponse(TypedDict, total=False):
462
+ workbenchSession: WorkbenchSession
463
+ executionKind: WorkbenchExecutionKind
464
+ executionId: str
465
+ status: WorkbenchExecutionStatus
466
+ outputMode: WorkbenchOutputMode
467
+ execution: WorkbenchExecutionLog
468
+ executionLog: WorkbenchExecutionLog
469
+ result: WorkbenchRuntimeEnvelope | WorkbenchBackgroundExecutionResult | WorkbenchOutputPreview
470
+ artifact: WorkbenchArtifact
471
+
472
+
473
+ class WorkbenchRun:
474
+ def __init__(self, client, platform_session_id: str, response: WorkbenchExecutionResponse):
475
+ self.client = client
476
+ self.platform_session_id = platform_session_id
477
+ self.response = response
478
+ self.execution_id = str(response["executionId"])
479
+ self.execution_kind = str(response["executionKind"])
480
+
481
+ def events(self, *, poll_interval_seconds: float = 0.5, limit: int = 200) -> Iterator[WorkbenchExecutionEvent]:
482
+ after_sequence: str | None = None
483
+ while True:
484
+ page = cast(WorkbenchExecutionEventsPage, self.client.executions.events(
485
+ self.platform_session_id,
486
+ self.execution_kind,
487
+ self.execution_id,
488
+ afterSequence=after_sequence,
489
+ limit=limit,
490
+ ))
491
+ for event in page.get("events", []):
492
+ yield event
493
+ sequence = event.get("sequence")
494
+ if isinstance(sequence, (str, int)):
495
+ after_sequence = str(sequence)
496
+ if page.get("hasMore") is True:
497
+ next_cursor = page.get("nextCursor")
498
+ if not isinstance(next_cursor, str) or not next_cursor:
499
+ raise StacklinkError("Execution event page reported hasMore without nextCursor.")
500
+ after_sequence = next_cursor
501
+ continue
502
+ current = cast(WorkbenchExecutionStateResponse, self.client.executions.get(
503
+ self.platform_session_id,
504
+ self.execution_kind,
505
+ self.execution_id,
506
+ ))
507
+ execution = current.get("execution", {})
508
+ if isinstance(execution, dict) and execution.get("status") in {
509
+ "completed",
510
+ "failed",
511
+ "cancelled",
512
+ "timed_out",
513
+ }:
514
+ return
515
+ time.sleep(poll_interval_seconds)
516
+
517
+ def wait(self, *, wait_timeout_ms: int | None = None, poll_interval_ms: int | None = None) -> WorkbenchExecutionStateResponse:
518
+ body = {
519
+ key: value
520
+ for key, value in {
521
+ "waitTimeoutMs": wait_timeout_ms,
522
+ "pollIntervalMs": poll_interval_ms,
523
+ }.items()
524
+ if value is not None
525
+ }
526
+ return cast(WorkbenchExecutionStateResponse, self.client.executions.wait(
527
+ self.platform_session_id,
528
+ self.execution_kind,
529
+ self.execution_id,
530
+ body,
531
+ ))
532
+
533
+ def cancel(self, reason: str | None = None) -> WorkbenchExecutionStateResponse:
534
+ return cast(WorkbenchExecutionStateResponse, self.client.executions.cancel(
535
+ self.platform_session_id,
536
+ self.execution_kind,
537
+ self.execution_id,
538
+ reason,
539
+ ))
540
+
541
+
542
+ def _wait_for_workbench_response(
543
+ client,
544
+ request: Mapping[str, Any],
545
+ accepted: WorkbenchExecutionResponse,
546
+ ) -> WorkbenchExecutionResponse:
547
+ session_id = request.get("sessionId")
548
+ execution_id = accepted.get("executionId")
549
+ execution_kind = accepted.get("executionKind")
550
+ if (
551
+ not isinstance(session_id, str)
552
+ or not isinstance(execution_id, str)
553
+ or execution_kind not in {"workbench_code", "workbench_bash"}
554
+ ):
555
+ return accepted
556
+
557
+ while True:
558
+ waited = cast(WorkbenchExecutionStateResponse, client.executions.wait(
559
+ session_id,
560
+ execution_kind,
561
+ execution_id,
562
+ {"waitTimeoutMs": 10_000, "pollIntervalMs": 500},
563
+ ))
564
+ execution = waited.get("execution")
565
+ if not isinstance(execution, dict) or execution.get("status") not in {
566
+ "completed",
567
+ "failed",
568
+ "cancelled",
569
+ "timed_out",
570
+ }:
571
+ continue
572
+ response: WorkbenchExecutionResponse = {
573
+ **accepted,
574
+ "executionId": str(execution.get("executionId") or execution.get("id") or execution_id),
575
+ "executionKind": cast(WorkbenchExecutionKind, execution.get("executionKind") or execution_kind),
576
+ "status": cast(WorkbenchExecutionStatus, execution["status"]),
577
+ "execution": cast(WorkbenchExecutionLog, execution),
578
+ "executionLog": cast(WorkbenchExecutionLog, execution),
579
+ }
580
+ output_mode = execution.get("outputMode")
581
+ if output_mode in {"inline", "preview", "events", "artifact"}:
582
+ response["outputMode"] = cast(WorkbenchOutputMode, output_mode)
583
+ if "result" in execution:
584
+ response["result"] = cast(
585
+ WorkbenchRuntimeEnvelope | WorkbenchBackgroundExecutionResult | WorkbenchOutputPreview,
586
+ execution["result"],
587
+ )
588
+ artifact = execution.get("artifact")
589
+ if isinstance(artifact, dict):
590
+ response["artifact"] = cast(WorkbenchArtifact, artifact)
591
+ return response
592
+
593
+
594
+ class WorkbenchFiles:
595
+ def __init__(self, sandbox: "SessionSandbox"):
596
+ self.sandbox = sandbox
597
+
598
+ def list(self) -> WorkbenchFileListResponse:
599
+ return cast(WorkbenchFileListResponse, self.sandbox.client.request(
600
+ "GET",
601
+ f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
602
+ query={"sessionId": self.sandbox.platform_session_id},
603
+ ))
604
+
605
+ def read(self, path: str) -> WorkbenchFileResponse:
606
+ return cast(WorkbenchFileResponse, self.sandbox.client.request(
607
+ "GET",
608
+ f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
609
+ query={"sessionId": self.sandbox.platform_session_id, "path": path},
610
+ ))
611
+
612
+ def upload(
613
+ self,
614
+ path: str,
615
+ content: str | bytes,
616
+ *,
617
+ content_type: str | None = None,
618
+ metadata: Mapping[str, Any] | None = None,
619
+ ) -> WorkbenchFileResponse:
620
+ raw = content.encode("utf-8") if isinstance(content, str) else content
621
+ body = {
622
+ "sessionId": self.sandbox.platform_session_id,
623
+ "path": path,
624
+ "contentBase64": base64.b64encode(raw).decode("ascii"),
625
+ "metadata": dict(metadata or {}),
626
+ }
627
+ if content_type is not None:
628
+ body["contentType"] = content_type
629
+ return cast(WorkbenchFileResponse, self.sandbox.client.request(
630
+ "PUT",
631
+ f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
632
+ json_body=body,
633
+ ))
634
+
635
+ def prepare_upload(self, path: str, *, content_type: str, size_bytes: int) -> WorkbenchFileUploadPrepareResponse:
636
+ return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
637
+ "POST",
638
+ f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/upload-url",
639
+ json_body={
640
+ "sessionId": self.sandbox.platform_session_id,
641
+ "path": path,
642
+ "contentType": content_type,
643
+ "sizeBytes": size_bytes,
644
+ },
645
+ ))
646
+
647
+ def finalize_upload(
648
+ self,
649
+ upload_id: str,
650
+ *,
651
+ metadata: Mapping[str, Any] | None = None,
652
+ ) -> WorkbenchFileMutationResponse:
653
+ return cast(WorkbenchFileMutationResponse, self.sandbox.client.request(
654
+ "POST",
655
+ f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/finalize-upload",
656
+ json_body={
657
+ "sessionId": self.sandbox.platform_session_id,
658
+ "uploadId": upload_id,
659
+ "metadata": dict(metadata or {}),
660
+ },
661
+ ))
662
+
663
+ def upload_large(
664
+ self,
665
+ path: str,
666
+ content: str | bytes,
667
+ *,
668
+ content_type: str | None = None,
669
+ metadata: Mapping[str, Any] | None = None,
670
+ ) -> WorkbenchFileMutationResponse:
671
+ raw = content.encode("utf-8") if isinstance(content, str) else content
672
+ resolved_content_type = content_type or (
673
+ "text/plain; charset=utf-8" if isinstance(content, str) else "application/octet-stream"
674
+ )
675
+ prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=len(raw))
676
+ upload = prepared.get("upload") if isinstance(prepared, dict) else None
677
+ if (
678
+ not isinstance(upload, dict)
679
+ or not isinstance(upload.get("uploadUrl"), str)
680
+ or not isinstance(upload.get("uploadId"), str)
681
+ ):
682
+ raise StacklinkError("Stacklink did not return a signed Workbench upload URL.")
683
+ headers = upload.get("requiredHeaders") if isinstance(upload.get("requiredHeaders"), dict) else {}
684
+ request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={str(key): str(value) for key, value in headers.items()})
685
+ try:
686
+ with urlopen(request, timeout=self.sandbox.client.timeout) as response:
687
+ if response.status >= 400:
688
+ raise StacklinkError(f"Workbench file upload failed with {response.status}.")
689
+ except HTTPError as error:
690
+ raise StacklinkError(f"Workbench file upload failed with {error.code}.") from error
691
+ return self.finalize_upload(
692
+ upload["uploadId"],
693
+ metadata=metadata,
694
+ )
695
+
696
+ def download(self, path: str) -> bytes:
697
+ signed = self.sandbox.client.request(
698
+ "GET",
699
+ f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/download-url",
700
+ query={"sessionId": self.sandbox.platform_session_id, "path": path},
701
+ )
702
+ download_url = signed.get("downloadUrl") if isinstance(signed, dict) else None
703
+ if not isinstance(download_url, str):
704
+ raise StacklinkError("Stacklink did not return a signed Workbench download URL.")
705
+ try:
706
+ with urlopen(Request(download_url, method="GET"), timeout=self.sandbox.client.timeout) as response:
707
+ return response.read()
708
+ except HTTPError as error:
709
+ raise StacklinkError(f"Workbench file download failed with {error.code}.") from error
710
+
711
+ def delete(self, path: str) -> WorkbenchFileMutationResponse:
712
+ return cast(WorkbenchFileMutationResponse, self.sandbox.client.request(
713
+ "DELETE",
714
+ f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
715
+ query={"sessionId": self.sandbox.platform_session_id, "path": path},
716
+ ))
717
+
718
+
719
+ class WorkbenchRuns:
720
+ def __init__(self, sandbox: "SessionSandbox"):
721
+ self.sandbox = sandbox
722
+
723
+ def list(
724
+ self,
725
+ *,
726
+ series_id: str | None = None,
727
+ cursor: str | None = None,
728
+ limit: int | None = None,
729
+ ) -> WorkbenchRunDefinitionListResponse:
730
+ query = {
731
+ "sessionId": self.sandbox.platform_session_id,
732
+ "workbenchSessionId": self.sandbox.ensure_session_id(),
733
+ }
734
+ if series_id is not None:
735
+ query["seriesId"] = series_id
736
+ if cursor is not None:
737
+ query["cursor"] = cursor
738
+ if limit is not None:
739
+ query["limit"] = limit
740
+ return cast(
741
+ WorkbenchRunDefinitionListResponse,
742
+ self.sandbox.client.request("GET", "/v1/workbench/run-definitions", query=query),
743
+ )
744
+
745
+ def get(self, run_definition_id: str) -> WorkbenchRunDefinitionResponse:
746
+ return cast(
747
+ WorkbenchRunDefinitionResponse,
748
+ self.sandbox.client.request(
749
+ "GET",
750
+ f"/v1/workbench/run-definitions/{run_definition_id}",
751
+ query={"sessionId": self.sandbox.platform_session_id},
752
+ ),
753
+ )
754
+
755
+ def update(
756
+ self,
757
+ run_definition_id: str,
758
+ *,
759
+ name: str | _WorkbenchUnset = _WORKBENCH_UNSET,
760
+ description: str | None | _WorkbenchUnset = _WORKBENCH_UNSET,
761
+ version_reason: str | None | _WorkbenchUnset = _WORKBENCH_UNSET,
762
+ ) -> WorkbenchRunDefinitionResponse:
763
+ body: dict[str, Any] = {"sessionId": self.sandbox.platform_session_id}
764
+ if not isinstance(name, _WorkbenchUnset):
765
+ body["name"] = name
766
+ if not isinstance(description, _WorkbenchUnset):
767
+ body["description"] = description
768
+ if not isinstance(version_reason, _WorkbenchUnset):
769
+ body["versionReason"] = version_reason
770
+ return cast(
771
+ WorkbenchRunDefinitionResponse,
772
+ self.sandbox.client.request(
773
+ "PATCH",
774
+ f"/v1/workbench/run-definitions/{run_definition_id}",
775
+ json_body=body,
776
+ ),
777
+ )
778
+
779
+ def save(
780
+ self,
781
+ execution_id: str,
782
+ name: str,
783
+ *,
784
+ series_id: str | None = None,
785
+ description: str | None = None,
786
+ metadata: Mapping[str, Any] | None = None,
787
+ ) -> WorkbenchRunDefinitionResponse:
788
+ body = {
789
+ "sessionId": self.sandbox.platform_session_id,
790
+ "workbenchSessionId": self.sandbox.ensure_session_id(),
791
+ "executionId": execution_id,
792
+ "name": name,
793
+ "metadata": dict(metadata or {}),
794
+ }
795
+ if series_id is not None:
796
+ body["seriesId"] = series_id
797
+ if description is not None:
798
+ body["description"] = description
799
+ return cast(
800
+ WorkbenchRunDefinitionResponse,
801
+ self.sandbox.client.request("POST", "/v1/workbench/run-definitions", json_body=body),
802
+ )
803
+
804
+ def replay(
805
+ self,
806
+ run_definition_id: str,
807
+ *,
808
+ background: bool = False,
809
+ timeout_ms: int | None = None,
810
+ environment_mode: WorkbenchReplayEnvironmentMode = "require_match",
811
+ idempotency_key: str,
812
+ ) -> WorkbenchRunComparisonResponse:
813
+ body = {
814
+ "sessionId": self.sandbox.platform_session_id,
815
+ "background": background,
816
+ "environmentMode": environment_mode,
817
+ "idempotencyKey": idempotency_key,
818
+ }
819
+ if timeout_ms is not None:
820
+ body["timeoutMs"] = timeout_ms
821
+ accepted = cast(WorkbenchRunComparisonResponse, self.sandbox.client.request(
822
+ "POST",
823
+ f"/v1/workbench/run-definitions/{run_definition_id}/replay",
824
+ json_body=body,
825
+ ))
826
+ if background:
827
+ return accepted
828
+ completed = _wait_for_workbench_response(
829
+ self.sandbox.client,
830
+ body,
831
+ accepted["execution"],
832
+ )
833
+ definition = accepted["runDefinition"]
834
+ execution_id = str(completed.get("executionId") or accepted["comparison"].get("replayExecutionId"))
835
+ comparison = accepted["comparison"]
836
+ comparison_deadline = time.monotonic() + _REUSABLE_RUN_COMPARISON_WAIT_SECONDS
837
+ while True:
838
+ current = self.get(run_definition_id).get("runDefinition", {})
839
+ replay = next(
840
+ (
841
+ entry
842
+ for entry in current.get("replays", [])
843
+ if entry.get("executionId") == execution_id
844
+ ),
845
+ None,
846
+ )
847
+ if isinstance(replay, dict) and isinstance(replay.get("comparison"), dict):
848
+ comparison = cast(WorkbenchRunComparison, replay["comparison"])
849
+ if comparison.get("status") in {"matched", "mismatched"}:
850
+ break
851
+ if time.monotonic() >= comparison_deadline:
852
+ break
853
+ time.sleep(_REUSABLE_RUN_COMPARISON_POLL_SECONDS)
854
+ execution_log = completed.get("executionLog")
855
+ metadata = execution_log.get("metadata") if isinstance(execution_log, dict) else None
856
+ environment = metadata.get("replayEnvironment") if isinstance(metadata, dict) else None
857
+ return {
858
+ **accepted,
859
+ "execution": completed,
860
+ "environment": cast(
861
+ WorkbenchRunEnvironmentResolution,
862
+ environment if isinstance(environment, dict) else accepted["environment"],
863
+ ),
864
+ "comparison": cast(
865
+ WorkbenchRunComparison,
866
+ comparison,
867
+ ),
868
+ }
869
+
870
+ def publish(
871
+ self,
872
+ run_definition_id: str,
873
+ expected_published_version: int | None,
874
+ ) -> WorkbenchRunActivationResponse:
875
+ return cast(WorkbenchRunActivationResponse, self.sandbox.client.request(
876
+ "POST",
877
+ f"/v1/workbench/run-definitions/{run_definition_id}/publish",
878
+ json_body={
879
+ "sessionId": self.sandbox.platform_session_id,
880
+ "expectedPublishedVersion": expected_published_version,
881
+ },
882
+ ))
883
+
884
+ def rollback(
885
+ self,
886
+ run_definition_id: str,
887
+ expected_published_version: int | None,
888
+ ) -> WorkbenchRunActivationResponse:
889
+ return cast(WorkbenchRunActivationResponse, self.sandbox.client.request(
890
+ "POST",
891
+ f"/v1/workbench/run-definitions/{run_definition_id}/rollback",
892
+ json_body={
893
+ "sessionId": self.sandbox.platform_session_id,
894
+ "expectedPublishedVersion": expected_published_version,
895
+ },
896
+ ))
897
+
898
+
899
+ class WorkbenchArtifacts:
900
+ def __init__(self, sandbox: "SessionSandbox"):
901
+ self.sandbox = sandbox
902
+
903
+ def list(self) -> WorkbenchArtifactListResponse:
904
+ return cast(WorkbenchArtifactListResponse, self.sandbox.client.request(
905
+ "GET",
906
+ "/v1/workbench/artifacts",
907
+ query={"workbenchSessionId": self.sandbox.ensure_session_id()},
908
+ ))
909
+
910
+ def promote(
911
+ self,
912
+ path: str,
913
+ *,
914
+ label: str | None = None,
915
+ content_type: str | None = None,
916
+ metadata: Mapping[str, Any] | None = None,
917
+ ) -> WorkbenchArtifactResponse:
918
+ body: dict[str, Any] = {
919
+ "sessionId": self.sandbox.platform_session_id,
920
+ "workbenchSessionId": self.sandbox.ensure_session_id(),
921
+ "path": path,
922
+ "metadata": dict(metadata or {}),
923
+ }
924
+ if label is not None:
925
+ body["label"] = label
926
+ if content_type is not None:
927
+ body["contentType"] = content_type
928
+ return cast(WorkbenchArtifactResponse, self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body))
929
+
930
+ def download_url(self, artifact_id: str) -> WorkbenchArtifactDownloadResponse:
931
+ return cast(WorkbenchArtifactDownloadResponse, self.sandbox.client.workbench.artifact_download_url(artifact_id))
932
+
933
+ def download(self, artifact_id: str) -> bytes:
934
+ return self.sandbox.client.workbench.download_artifact(artifact_id)
935
+
936
+ def delete(self, artifact_id: str, *, path: str | None = None) -> WorkbenchArtifactResponse:
937
+ return cast(WorkbenchArtifactResponse, self.sandbox.client.request(
938
+ "DELETE",
939
+ f"/v1/workbench/artifacts/{artifact_id}",
940
+ query={
941
+ "sessionId": self.sandbox.platform_session_id,
942
+ "workbenchSessionId": self.sandbox.ensure_session_id(),
943
+ "path": path,
944
+ },
945
+ ))
946
+
947
+
948
+ class SessionSandbox:
949
+ def __init__(self, client, platform_session_id: str):
950
+ self.client = client
951
+ self.platform_session_id = platform_session_id
952
+ self.workbench_session_id: str | None = None
953
+ self.files = WorkbenchFiles(self)
954
+ self.runs = WorkbenchRuns(self)
955
+ self.artifacts = WorkbenchArtifacts(self)
956
+
957
+ def use(
958
+ self,
959
+ workbench_session_id: str | None = None,
960
+ *,
961
+ create_new: bool = False,
962
+ metadata: Mapping[str, Any] | None = None,
963
+ ) -> WorkbenchSessionUseResponse:
964
+ response = cast(WorkbenchSessionUseResponse, self.client.request(
965
+ "POST",
966
+ "/v1/workbench/sessions",
967
+ json_body={
968
+ "sessionId": self.platform_session_id,
969
+ "workbenchSessionId": workbench_session_id,
970
+ "createNew": create_new,
971
+ "metadata": dict(metadata or {}),
972
+ },
973
+ ))
974
+ self.workbench_session_id = str(response["workbenchSession"]["id"])
975
+ return response
976
+
977
+ def ensure_session_id(self) -> str:
978
+ if self.workbench_session_id is None:
979
+ self.use()
980
+ assert self.workbench_session_id is not None
981
+ return self.workbench_session_id
982
+
983
+ def run(self, body: WorkbenchRunInput) -> WorkbenchRun:
984
+ request = {
985
+ **body,
986
+ "sessionId": self.platform_session_id,
987
+ "workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
988
+ "operation": "run_code",
989
+ }
990
+ response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/run", json_body=request)
991
+ if body.get("background") is not True:
992
+ response = _wait_for_workbench_response(self.client, request, response)
993
+ self.workbench_session_id = str(response["workbenchSession"]["id"])
994
+ return WorkbenchRun(self.client, self.platform_session_id, response)
995
+
996
+ def bash(self, body: WorkbenchBashInput) -> WorkbenchRun:
997
+ request = {
998
+ **body,
999
+ "sessionId": self.platform_session_id,
1000
+ "workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
1001
+ }
1002
+ response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/bash", json_body=request)
1003
+ if body.get("background") is not True:
1004
+ response = _wait_for_workbench_response(self.client, request, response)
1005
+ self.workbench_session_id = str(response["workbenchSession"]["id"])
1006
+ return WorkbenchRun(self.client, self.platform_session_id, response)
1007
+
1008
+ def destroy(self) -> WorkbenchSessionDestroyResponse:
1009
+ return cast(WorkbenchSessionDestroyResponse, self.client.workbench.destroy(
1010
+ self.platform_session_id,
1011
+ self.ensure_session_id(),
1012
+ ))
1013
+
1014
+ def delete(self) -> WorkbenchSessionDeleteReceipt:
1015
+ result = self.client.workbench.delete_session(
1016
+ self.platform_session_id,
1017
+ self.ensure_session_id(),
1018
+ )
1019
+ self.workbench_session_id = None
1020
+ return result
1021
+
1022
+
1023
+ class WorkbenchResource:
1024
+ def __init__(self, client):
1025
+ self.client = client
1026
+
1027
+ def bind(self, platform_session_id: str) -> SessionSandbox:
1028
+ return SessionSandbox(self.client, platform_session_id)
1029
+
1030
+ def models(self):
1031
+ return self.client.request("GET", "/v1/workbench/models")
1032
+
1033
+ def run(self, body: dict[str, Any]):
1034
+ response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body))
1035
+ if body.get("operation") == "run_code" and body.get("background") is not True:
1036
+ return _wait_for_workbench_response(self.client, body, response)
1037
+ return response
1038
+
1039
+ def bash(self, body: dict[str, Any]):
1040
+ response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/bash", json_body=body))
1041
+ if body.get("background") is not True:
1042
+ return _wait_for_workbench_response(self.client, body, response)
1043
+ return response
1044
+
1045
+ def destroy(self, session_id: str, workbench_session_id: str):
1046
+ return self.run({
1047
+ "sessionId": session_id,
1048
+ "workbenchSessionId": workbench_session_id,
1049
+ "operation": "destroy",
1050
+ })
1051
+
1052
+ def delete_session(self, session_id: str, workbench_session_id: str) -> WorkbenchSessionDeleteReceipt:
1053
+ return cast(WorkbenchSessionDeleteReceipt, self.client.request(
1054
+ "DELETE",
1055
+ f"/v1/workbench/sessions/{workbench_session_id}",
1056
+ query={"sessionId": session_id},
1057
+ ))
1058
+
1059
+ def sessions(self, session_id: str):
1060
+ return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
1061
+
1062
+ def list_sessions(self, **query):
1063
+ return self.client.request("GET", "/v1/workbench/sessions", query=query)
1064
+
1065
+ def get_session(self, workbench_session_id: str):
1066
+ return self.client.request("GET", f"/v1/workbench/sessions/{workbench_session_id}")
1067
+
1068
+ def usage(self, workbench_session_id: str):
1069
+ return self.client.request("GET", f"/v1/workbench/sessions/{workbench_session_id}/usage")
1070
+
1071
+ def list_artifacts(self, **query):
1072
+ return self.client.request("GET", "/v1/workbench/artifacts", query=query)
1073
+
1074
+ def artifact_download_url(self, artifact_id: str):
1075
+ return self.client.request("GET", f"/v1/workbench/artifacts/{artifact_id}/download-url")
1076
+
1077
+ def download_artifact(self, artifact_id: str) -> bytes:
1078
+ return self.client.request_raw("GET", f"/v1/workbench/artifacts/{artifact_id}/download")
1079
+
1080
+ def list_budgets(self, **query):
1081
+ return self.client.request("GET", "/v1/workbench/budgets", query=query)
1082
+
1083
+ def create_budget(self, body: dict[str, Any]):
1084
+ return self.client.request("POST", "/v1/workbench/budgets", json_body=body)
1085
+
1086
+ def update_budget(self, budget_policy_id: str, body: dict[str, Any]):
1087
+ return self.client.request("PATCH", f"/v1/workbench/budgets/{budget_policy_id}", json_body=body)
1088
+
1089
+ def delete_budget(self, budget_policy_id: str):
1090
+ return self.client.request("DELETE", f"/v1/workbench/budgets/{budget_policy_id}")