stacklink-sdk 0.3.19__tar.gz → 0.4.0__tar.gz

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.
Files changed (43) hide show
  1. {stacklink_sdk-0.3.19/stacklink_sdk.egg-info → stacklink_sdk-0.4.0}/PKG-INFO +1 -1
  2. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/pyproject.toml +1 -1
  3. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/bots.py +23 -1
  4. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/client.py +8 -11
  5. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/errors.py +4 -3
  6. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/mcp.py +4 -1
  7. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/workbench.py +109 -36
  8. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0/stacklink_sdk.egg-info}/PKG-INFO +1 -1
  9. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_client.py +68 -2
  10. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/LICENSE +0 -0
  11. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/README.md +0 -0
  12. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/setup.cfg +0 -0
  13. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/__init__.py +0 -0
  14. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/api_keys.py +0 -0
  15. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/approvals.py +0 -0
  16. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/artifacts.py +0 -0
  17. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/auth_configs.py +0 -0
  18. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/billing.py +0 -0
  19. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/claude_agent.py +0 -0
  20. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/connected_accounts.py +0 -0
  21. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/executions.py +0 -0
  22. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/knowledge.py +0 -0
  23. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/logs.py +0 -0
  24. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/memory.py +0 -0
  25. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/openai_agents.py +0 -0
  26. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/openai_memory.py +0 -0
  27. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/projects.py +0 -0
  28. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/providers.py +0 -0
  29. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/sessions.py +0 -0
  30. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/tools.py +0 -0
  31. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/triggers.py +0 -0
  32. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/types.py +0 -0
  33. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/webhooks.py +0 -0
  34. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink/x_chat.py +0 -0
  35. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/SOURCES.txt +0 -0
  36. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
  37. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/requires.txt +0 -0
  38. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/top_level.txt +0 -0
  39. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_google_docs_provider.py +0 -0
  40. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_google_drive_provider.py +0 -0
  41. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_google_sheets_provider.py +0 -0
  42. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_openai_memory.py +0 -0
  43. {stacklink_sdk-0.3.19 → stacklink_sdk-0.4.0}/tests/test_zoho_books_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.3.19
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stacklink-sdk"
7
- version = "0.3.19"
7
+ version = "0.4.0"
8
8
  description = "Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing."
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -53,6 +53,12 @@ class BotExecutionWait(TypedDict):
53
53
  kind: Literal["execution"]
54
54
  id: str
55
55
 
56
+ class BotAttachmentPreparationWait(TypedDict):
57
+ kind: Literal["attachment_preparation"]
58
+ id: str
59
+ fileId: str
60
+
61
+
56
62
 
57
63
  class BotRequirement(TypedDict):
58
64
  capabilityId: str
@@ -71,6 +77,22 @@ class BotAttachment(TypedDict, total=False):
71
77
  facts: dict[str, Any]
72
78
  warnings: list[dict[str, str]]
73
79
  error: dict[str, str] | None
80
+ transcription: dict[Literal["state"], Literal["queued", "transcribing", "reconciling", "completed", "failed"]]
81
+
82
+
83
+ class BotAudioPlayback(TypedDict):
84
+ url: str
85
+ expiresAt: str
86
+ mediaType: Literal["audio/mpeg"]
87
+
88
+
89
+ class _BotFilePlayback(TypedDict, total=False):
90
+ playback: BotAudioPlayback
91
+
92
+
93
+ class BotFileLink(_BotFilePlayback):
94
+ url: str
95
+ expiresAt: str
74
96
 
75
97
 
76
98
  class BotFileUpload(TypedDict):
@@ -114,7 +136,7 @@ class BotRun(TypedDict, total=False):
114
136
  finalText: str | None
115
137
  output: Any
116
138
  error: Any
117
- waitingOperation: BotConnectionAction | BotExecutionWait | None
139
+ waitingOperation: BotConnectionAction | BotExecutionWait | BotAttachmentPreparationWait | None
118
140
  connection: BotConnectionAction | None
119
141
  capabilities: list[BotCapabilityFact]
120
142
  requirements: list[BotRequirement]
@@ -37,7 +37,7 @@ from .types import Json, Transport
37
37
  from .webhooks import WebhooksResource
38
38
  from .workbench import WorkbenchResource
39
39
 
40
- STACKLINK_PYTHON_SDK_VERSION = "0.3.19"
40
+ STACKLINK_PYTHON_SDK_VERSION = "0.4.0"
41
41
  STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
42
42
 
43
43
 
@@ -356,18 +356,12 @@ class Stacklink:
356
356
  "user-agent": STACKLINK_PYTHON_USER_AGENT,
357
357
  "x-stacklink-client-channel": "python_sdk",
358
358
  "x-stacklink-client-name": "stacklink-python",
359
+ "x-stacklink-workbench-protocol": "2",
359
360
  "x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
360
361
  }
361
362
 
362
363
  def _handle_response(self, status: int, headers: Mapping[str, str], body: Json) -> Json:
363
- if status < 400:
364
- return body
365
- message = body.get("message") if isinstance(body, dict) else None
366
- code = body.get("code") if isinstance(body, dict) else None
367
- details = body.get("details") if isinstance(body, dict) else None
368
- if status == 429:
369
- raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
370
- raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
364
+ return _handle_response(status, headers, body)
371
365
 
372
366
  def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
373
367
  event_id = None
@@ -671,6 +665,7 @@ class AsyncStacklink:
671
665
  "user-agent": STACKLINK_PYTHON_USER_AGENT,
672
666
  "x-stacklink-client-channel": "python_sdk",
673
667
  "x-stacklink-client-name": "stacklink-python",
668
+ "x-stacklink-workbench-protocol": "2",
674
669
  "x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
675
670
  }
676
671
 
@@ -874,9 +869,11 @@ def _handle_response(status: int, headers: Mapping[str, str], body: Json) -> Jso
874
869
  message = body.get("message") if isinstance(body, dict) else None
875
870
  code = body.get("code") if isinstance(body, dict) else None
876
871
  details = body.get("details") if isinstance(body, dict) else None
872
+ failure_id = body.get("failureId") if isinstance(body, dict) else None
873
+ failure_id = failure_id or headers.get("x-stacklink-failure-id")
877
874
  if status == 429:
878
- raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
879
- raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
875
+ raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
876
+ raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
880
877
 
881
878
 
882
879
  def _timeout_error(timeout_seconds: float, request_id: str) -> StacklinkError:
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  class StacklinkError(Exception):
4
- def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None):
4
+ def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None, failure_id: str | None = None):
5
5
  super().__init__(message)
6
+ self.failure_id = failure_id
6
7
  self.status_code = status_code
7
8
  self.code = code
8
9
  self.details = details
@@ -10,7 +11,7 @@ class StacklinkError(Exception):
10
11
 
11
12
 
12
13
  class RateLimitError(StacklinkError):
13
- def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None):
14
- super().__init__(message, status_code=status_code, code=code, details=details)
14
+ def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None, failure_id: str | None = None):
15
+ super().__init__(message, status_code=status_code, code=code, details=details, headers=headers, failure_id=failure_id)
15
16
  self.headers = headers or {}
16
17
 
@@ -77,6 +77,7 @@ def unwrap_tool_result(response: Any):
77
77
  status_code=400,
78
78
  code=str(error.get("code")) if error.get("code") is not None else None,
79
79
  details=error.get("data"),
80
+ failure_id=error["data"].get("failureId") if isinstance(error.get("data"), dict) else None,
80
81
  )
81
82
  result = response.get("result")
82
83
  if not isinstance(result, dict):
@@ -87,7 +88,9 @@ def unwrap_tool_result(response: Any):
87
88
  for item in result.get("content", [])
88
89
  if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str)
89
90
  )
90
- raise StacklinkError(text or "Stacklink MCP tool call failed.", status_code=400, code="mcp_tool_error", details=result)
91
+ structured = result.get("structuredContent")
92
+ failure = structured.get("error") if isinstance(structured, dict) else None
93
+ raise StacklinkError(text or "Stacklink MCP tool call failed.", status_code=400, code="mcp_tool_error", details=result, failure_id=failure.get("failureId") if isinstance(failure, dict) else None)
91
94
  if "structuredContent" in result:
92
95
  return result["structuredContent"]
93
96
  content = result.get("content")
@@ -3,9 +3,10 @@ from __future__ import annotations
3
3
  import base64
4
4
  import time
5
5
  import threading
6
+ from uuid import uuid4
6
7
  from email.utils import parsedate_to_datetime
7
8
  import httpx
8
- from typing import Any, Iterator, Literal, Mapping, TypedDict, cast
9
+ from typing import Any, BinaryIO, Iterator, Literal, Mapping, TypedDict, cast
9
10
  from urllib.error import HTTPError
10
11
  from urllib.request import Request, urlopen
11
12
 
@@ -319,8 +320,21 @@ class WorkbenchOutputPreview(TypedDict):
319
320
  preview: str
320
321
 
321
322
 
323
+ class WorkbenchStorageOperation(TypedDict, total=False):
324
+ operationId: str
325
+ workbenchSessionId: str
326
+ kind: Literal["delete", "upload_finalize", "artifact_promote"]
327
+ status: Literal["pending", "processing", "completed", "failed"]
328
+ statusUrl: str
329
+ stage: str
330
+ retryAfterMs: int
331
+ pendingReason: str
332
+ result: dict[str, Any]
333
+ error: Any
334
+
335
+
322
336
  class WorkbenchFile(TypedDict, total=False):
323
- id: str
337
+ id: str | None
324
338
  path: str
325
339
  contentType: str
326
340
  sizeBytes: int | str
@@ -340,9 +354,12 @@ class WorkbenchFileResponse(TypedDict):
340
354
  class WorkbenchFileListResponse(TypedDict, total=False):
341
355
  files: list[WorkbenchFile]
342
356
  runtimeFiles: list[WorkbenchFile]
357
+ nextCursor: str | None
358
+ authoritative: bool
343
359
 
344
360
 
345
361
  class WorkbenchFileMutationResponse(TypedDict, total=False):
362
+ operation: WorkbenchStorageOperation
346
363
  file: WorkbenchFile
347
364
  uploadId: str
348
365
  finalized: bool
@@ -365,13 +382,16 @@ class WorkbenchFileUploadPrepareResponse(TypedDict):
365
382
  upload: WorkbenchFileSignedUpload
366
383
 
367
384
 
368
- class WorkbenchFileSignedDownload(TypedDict):
385
+ class WorkbenchFileSignedDownload(TypedDict, total=False):
369
386
  file: WorkbenchFile
370
387
  downloadUrl: str
371
388
  expiresAt: str
389
+ requiredHeaders: dict[str, str]
372
390
 
373
391
 
374
392
  class WorkbenchArtifact(TypedDict, total=False):
393
+ operationId: str | None
394
+ workbenchSessionId: str
375
395
  id: str
376
396
  artifactRef: str
377
397
  path: str
@@ -691,11 +711,11 @@ class WorkbenchFiles:
691
711
  def __init__(self, sandbox: "SessionSandbox"):
692
712
  self.sandbox = sandbox
693
713
 
694
- def list(self) -> WorkbenchFileListResponse:
714
+ def list(self, *, cursor: str | None = None, limit: int = 100) -> WorkbenchFileListResponse:
695
715
  return cast(WorkbenchFileListResponse, self.sandbox.client.request(
696
716
  "GET",
697
717
  f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
698
- query={"sessionId": self.sandbox.platform_session_id},
718
+ query={"sessionId": self.sandbox.platform_session_id, "cursor": cursor, "limit": limit},
699
719
  ))
700
720
 
701
721
  def read(self, path: str) -> WorkbenchFileResponse:
@@ -712,33 +732,42 @@ class WorkbenchFiles:
712
732
  *,
713
733
  content_type: str | None = None,
714
734
  metadata: Mapping[str, Any] | None = None,
735
+ upload_id: str | None = None,
715
736
  ) -> WorkbenchFileResponse:
716
737
  raw = content.encode("utf-8") if isinstance(content, str) else content
717
738
  body = {
718
739
  "sessionId": self.sandbox.platform_session_id,
719
740
  "path": path,
741
+ "uploadId": upload_id or str(uuid4()),
720
742
  "contentBase64": base64.b64encode(raw).decode("ascii"),
721
743
  "metadata": dict(metadata or {}),
722
744
  }
723
745
  if content_type is not None:
724
746
  body["contentType"] = content_type
725
- return cast(WorkbenchFileResponse, self.sandbox.client.request(
726
- "PUT",
727
- f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
728
- json_body=body,
729
- ))
730
-
731
- def prepare_upload(self, path: str, *, content_type: str, size_bytes: int) -> WorkbenchFileUploadPrepareResponse:
732
- return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
733
- "POST",
734
- f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/upload-url",
735
- json_body={
736
- "sessionId": self.sandbox.platform_session_id,
737
- "path": path,
738
- "contentType": content_type,
739
- "sizeBytes": size_bytes,
740
- },
741
- ))
747
+ workspace_id = self.sandbox.ensure_session_id()
748
+ try:
749
+ result = self.sandbox.client.request("PUT", f"/v1/workbench/sessions/{workspace_id}/files", json_body=body)
750
+ return cast(WorkbenchFileResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, workspace_id, result))
751
+ except StacklinkError as error:
752
+ details = error.details if isinstance(error.details, dict) else {}
753
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
754
+ details={**details, "uploadId": body["uploadId"], "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
755
+ headers=error.headers, failure_id=error.failure_id) from error
756
+
757
+ def prepare_upload(self, path: str, *, content_type: str, size_bytes: int, upload_id: str | None = None) -> WorkbenchFileUploadPrepareResponse:
758
+ upload_id = upload_id or str(uuid4())
759
+ workspace_id = self.sandbox.ensure_session_id()
760
+ try:
761
+ return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
762
+ "POST", f"/v1/workbench/sessions/{workspace_id}/files/upload-url",
763
+ json_body={"sessionId": self.sandbox.platform_session_id, "path": path,
764
+ "contentType": content_type, "sizeBytes": size_bytes, "uploadId": upload_id},
765
+ ))
766
+ except StacklinkError as error:
767
+ details = error.details if isinstance(error.details, dict) else {}
768
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
769
+ details={**details, "uploadId": upload_id, "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
770
+ headers=error.headers, failure_id=error.failure_id) from error
742
771
 
743
772
  def finalize_upload(
744
773
  self,
@@ -746,7 +775,7 @@ class WorkbenchFiles:
746
775
  *,
747
776
  metadata: Mapping[str, Any] | None = None,
748
777
  ) -> WorkbenchFileMutationResponse:
749
- return cast(WorkbenchFileMutationResponse, self.sandbox.client.request(
778
+ result = self.sandbox.client.request(
750
779
  "POST",
751
780
  f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/finalize-upload",
752
781
  json_body={
@@ -754,21 +783,31 @@ class WorkbenchFiles:
754
783
  "uploadId": upload_id,
755
784
  "metadata": dict(metadata or {}),
756
785
  },
757
- ))
786
+ )
787
+ return cast(WorkbenchFileMutationResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
758
788
 
759
789
  def upload_large(
760
790
  self,
761
791
  path: str,
762
- content: str | bytes,
792
+ content: str | bytes | BinaryIO,
763
793
  *,
764
794
  content_type: str | None = None,
765
795
  metadata: Mapping[str, Any] | None = None,
796
+ transfer_timeout_seconds: float = 300.0,
797
+ upload_id: str | None = None,
766
798
  ) -> WorkbenchFileMutationResponse:
767
799
  raw = content.encode("utf-8") if isinstance(content, str) else content
768
800
  resolved_content_type = content_type or (
769
801
  "text/plain; charset=utf-8" if isinstance(content, str) else "application/octet-stream"
770
802
  )
771
- prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=len(raw))
803
+ if isinstance(raw, bytes):
804
+ size_bytes = len(raw)
805
+ else:
806
+ position = raw.tell()
807
+ raw.seek(0, 2)
808
+ size_bytes = raw.tell() - position
809
+ raw.seek(position)
810
+ prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=size_bytes, upload_id=upload_id)
772
811
  upload = prepared.get("upload") if isinstance(prepared, dict) else None
773
812
  if (
774
813
  not isinstance(upload, dict)
@@ -777,13 +816,16 @@ class WorkbenchFiles:
777
816
  ):
778
817
  raise StacklinkError("Stacklink did not return a signed Workbench upload URL.")
779
818
  headers = upload.get("requiredHeaders") if isinstance(upload.get("requiredHeaders"), dict) else {}
780
- request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={str(key): str(value) for key, value in headers.items()})
819
+ request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={**{str(key): str(value) for key, value in headers.items()}, "Content-Length": str(size_bytes)})
781
820
  try:
782
- with urlopen(request, timeout=self.sandbox.client.timeout) as response:
821
+ with urlopen(request, timeout=transfer_timeout_seconds) as response:
783
822
  if response.status >= 400:
784
823
  raise StacklinkError(f"Workbench file upload failed with {response.status}.")
785
824
  except HTTPError as error:
786
- raise StacklinkError(f"Workbench file upload failed with {error.code}.") from error
825
+ if error.code != 412:
826
+ raise StacklinkError(f"Workbench file upload failed with {error.code}.", status_code=error.code, code="workbench_file_direct_upload_failed", details={"uploadId": upload["uploadId"], "workbenchSessionId": self.sandbox.ensure_session_id(), "sessionId": self.sandbox.platform_session_id}) from error
827
+ except (TimeoutError, OSError) as error:
828
+ raise StacklinkError("Upload outcome is unknown. Finalize the same uploadId to reconcile.", code="workbench_upload_outcome_unknown", details={"uploadId": upload["uploadId"]}) from error
787
829
  return self.finalize_upload(
788
830
  upload["uploadId"],
789
831
  metadata=metadata,
@@ -799,7 +841,7 @@ class WorkbenchFiles:
799
841
  if not isinstance(download_url, str):
800
842
  raise StacklinkError("Stacklink did not return a signed Workbench download URL.")
801
843
  try:
802
- with urlopen(Request(download_url, method="GET"), timeout=self.sandbox.client.timeout) as response:
844
+ with urlopen(Request(download_url, method="GET", headers=signed.get("requiredHeaders", {})), timeout=300.0) as response:
803
845
  return response.read()
804
846
  except HTTPError as error:
805
847
  raise StacklinkError(f"Workbench file download failed with {error.code}.") from error
@@ -1010,18 +1052,27 @@ class WorkbenchArtifacts:
1010
1052
  label: str | None = None,
1011
1053
  content_type: str | None = None,
1012
1054
  metadata: Mapping[str, Any] | None = None,
1055
+ artifact_ref: str | None = None,
1013
1056
  ) -> WorkbenchArtifactResponse:
1014
1057
  body: dict[str, Any] = {
1015
1058
  "sessionId": self.sandbox.platform_session_id,
1016
1059
  "workbenchSessionId": self.sandbox.ensure_session_id(),
1017
1060
  "path": path,
1061
+ "artifactRef": artifact_ref or f"wba_{uuid4()}",
1018
1062
  "metadata": dict(metadata or {}),
1019
1063
  }
1020
1064
  if label is not None:
1021
1065
  body["label"] = label
1022
1066
  if content_type is not None:
1023
1067
  body["contentType"] = content_type
1024
- return cast(WorkbenchArtifactResponse, self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body))
1068
+ try:
1069
+ result = self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body)
1070
+ return cast(WorkbenchArtifactResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
1071
+ except StacklinkError as error:
1072
+ details = error.details if isinstance(error.details, dict) else {}
1073
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
1074
+ details={**details, "artifactRef": body["artifactRef"], "workbenchSessionId": body["workbenchSessionId"], "sessionId": body["sessionId"]},
1075
+ headers=error.headers, failure_id=error.failure_id) from error
1025
1076
 
1026
1077
  def download_url(self, artifact_id: str) -> WorkbenchArtifactDownloadResponse:
1027
1078
  return cast(WorkbenchArtifactDownloadResponse, self.sandbox.client.workbench.artifact_download_url(artifact_id))
@@ -1134,6 +1185,10 @@ class WorkbenchResource:
1134
1185
 
1135
1186
  def run(self, body: dict[str, Any]):
1136
1187
  response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body))
1188
+ if isinstance(response.get("operation"), dict):
1189
+ operation = response["operation"]
1190
+ completed = self.wait_operation(body["sessionId"], operation["workbenchSessionId"], operation["operationId"])
1191
+ return {**{key: value for key, value in response.items() if key != "operation"}, **completed}
1137
1192
  if body.get("operation") == "run_code" and body.get("background") is not True:
1138
1193
  return _wait_for_workbench_response(self.client, body, response)
1139
1194
  return response
@@ -1162,12 +1217,30 @@ class WorkbenchResource:
1162
1217
  "operation": "destroy",
1163
1218
  })
1164
1219
 
1220
+ def operation(self, session_id: str, workbench_session_id: str, operation_id: str):
1221
+ return self.client.request("GET", f"/v1/workbench/sessions/{workbench_session_id}/operations/{operation_id}", query={"sessionId": session_id})
1222
+
1223
+ def wait_operation(self, session_id: str, workbench_session_id: str, operation_id: str, *, timeout_seconds: float = 300.0):
1224
+ deadline = time.monotonic() + timeout_seconds
1225
+ while time.monotonic() < deadline:
1226
+ try:
1227
+ value = self.operation(session_id, workbench_session_id, operation_id)["operation"]
1228
+ except StacklinkError as error:
1229
+ raise StacklinkError("Operation observation stopped. Resume with the same operation ID.", status_code=error.status_code, code="workbench_operation_observation_failed", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id, "causeCode": error.code}) from error
1230
+ if value["status"] == "completed":
1231
+ return value["result"]
1232
+ if value["status"] == "failed":
1233
+ raise StacklinkError("Workbench operation needs attention.", code="workbench_operation_failed", details=value)
1234
+ time.sleep(min(max(0.1, value.get("retryAfterMs", 1000) / 1000), max(0, deadline - time.monotonic())))
1235
+ raise StacklinkError("Workbench operation is still running. Resume with its operation ID.", code="workbench_operation_pending", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id})
1236
+
1237
+ def finish_operation(self, session_id: str, workbench_session_id: str, result):
1238
+ operation = result.get("operation") if isinstance(result, dict) else None
1239
+ return self.wait_operation(session_id, workbench_session_id, operation["operationId"]) if isinstance(operation, dict) else result
1240
+
1165
1241
  def delete_session(self, session_id: str, workbench_session_id: str) -> WorkbenchSessionDeleteReceipt:
1166
- return cast(WorkbenchSessionDeleteReceipt, self.client.request(
1167
- "DELETE",
1168
- f"/v1/workbench/sessions/{workbench_session_id}",
1169
- query={"sessionId": session_id},
1170
- ))
1242
+ result = self.client.request("DELETE", f"/v1/workbench/sessions/{workbench_session_id}", query={"sessionId": session_id})
1243
+ return cast(WorkbenchSessionDeleteReceipt, self.finish_operation(session_id, workbench_session_id, result))
1171
1244
 
1172
1245
  def sessions(self, session_id: str):
1173
1246
  return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.3.19
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -20,7 +20,7 @@ from stacklink import (
20
20
  create_stacklink_claude_agent_tool_config,
21
21
  create_stacklink_openai_agents_tools,
22
22
  )
23
- from stacklink.workbench import WorkbenchRun
23
+ from stacklink.workbench import WorkbenchRun, SessionSandbox
24
24
 
25
25
 
26
26
  @contextmanager
@@ -63,6 +63,52 @@ def stalled_stream_server(*, final_result=False, execution_page=False):
63
63
 
64
64
 
65
65
  class StacklinkClientTests(unittest.TestCase):
66
+ def test_workbench_lost_acknowledgement_keeps_upload_and_artifact_identity(self):
67
+ bodies = []
68
+ def transport(method, path, headers, body, query, timeout):
69
+ bodies.append(body)
70
+ return 504, {}, {"code": "outbound_http_timeout", "message": "Lost acknowledgement"}
71
+ client = Stacklink(api_key="test", transport=transport, max_attempts=1)
72
+ sandbox = SessionSandbox(client, "session-1")
73
+ sandbox.workbench_session_id = "workspace-1"
74
+ with self.assertRaises(StacklinkError) as caught:
75
+ sandbox.files.upload("नतीजा.bin", b"result")
76
+ upload_id = caught.exception.details["uploadId"]
77
+ with self.assertRaises(StacklinkError):
78
+ sandbox.files.upload("नतीजा.bin", b"result", upload_id=upload_id)
79
+ self.assertEqual(bodies[0]["uploadId"], bodies[1]["uploadId"])
80
+ with self.assertRaises(StacklinkError) as caught:
81
+ sandbox.artifacts.promote("नतीजा.bin")
82
+ artifact_ref = caught.exception.details["artifactRef"]
83
+ with self.assertRaises(StacklinkError):
84
+ sandbox.artifacts.promote("नतीजा.bin", artifact_ref=artifact_ref)
85
+ self.assertEqual(bodies[2]["artifactRef"], bodies[3]["artifactRef"])
86
+
87
+ def test_workbench_storage_wait_retains_identity_and_never_repeats_deletion(self):
88
+ calls = []
89
+ receipt = {"deleted": True, "databaseDeleted": True, "workbenchSessionId": "workspace-1"}
90
+ def transport(method, path, headers, body, query, timeout):
91
+ calls.append((method, path))
92
+ if method == "DELETE":
93
+ return 202, {}, {"operation": {"operationId": "operation-1"}}
94
+ return 200, {}, {"operation": {"operationId": "operation-1", "status": "completed", "result": receipt}}
95
+ client = Stacklink(api_key="test", transport=transport)
96
+ self.assertEqual(client.workbench.delete_session("session-1", "workspace-1"), receipt)
97
+ self.assertEqual([method for method, _ in calls], ["DELETE", "GET"])
98
+ with self.assertRaises(StacklinkError) as caught:
99
+ client.workbench.wait_operation("session-1", "workspace-1", "operation-1", timeout_seconds=0)
100
+ self.assertEqual(caught.exception.details, {"sessionId": "session-1", "workbenchSessionId": "workspace-1", "operationId": "operation-1"})
101
+ self.assertEqual(len(calls), 2)
102
+
103
+ def test_workbench_storage_observation_failure_keeps_resume_handle(self):
104
+ def transport(method, path, headers, body, query, timeout):
105
+ return 403, {}, {"code": "access_denied", "message": "denied"}
106
+ client = Stacklink(api_key="test", transport=transport)
107
+ with self.assertRaises(StacklinkError) as caught:
108
+ client.workbench.wait_operation("session-1", "workspace-1", "operation-1")
109
+ self.assertEqual(caught.exception.code, "workbench_operation_observation_failed")
110
+ self.assertEqual(caught.exception.details["operationId"], "operation-1")
111
+
66
112
  def test_bot_avatar_recipe_is_preserved_for_sync_and_async_create(self):
67
113
  recipe = {"version": 1, "style": "clay", "seed": "Sales assistant", "tone": "mint"}
68
114
  payloads = []
@@ -804,6 +850,23 @@ class StacklinkClientTests(unittest.TestCase):
804
850
  lines.extend(decoder.finish())
805
851
  self.assertEqual(lines, ['data: {"value":"नमस्ते"}', ''])
806
852
 
853
+ def test_failure_reference_survives_http_and_mcp_errors(self):
854
+ from stacklink.mcp import unwrap_tool_result
855
+ for status, body in [(502, {"failureId": "failure-body"}), (429, {})]:
856
+ def transport(method, path, headers, request_body, query, timeout):
857
+ return status, {"x-stacklink-failure-id": "failure-header"}, body
858
+ client = Stacklink(api_key="ppk_test", transport=transport)
859
+ with self.assertRaises(StacklinkError) as caught:
860
+ client.tools.search({"sessionId": "session", "query": "gmail"})
861
+ self.assertEqual(caught.exception.failure_id, body.get("failureId", "failure-header"))
862
+ for response in [
863
+ {"error": {"code": -32603, "data": {"failureId": "failure-mcp"}}},
864
+ {"result": {"isError": True, "structuredContent": {"error": {"failureId": "failure-mcp"}}}},
865
+ ]:
866
+ with self.assertRaises(StacklinkError) as caught:
867
+ unwrap_tool_result(response)
868
+ self.assertEqual(caught.exception.failure_id, "failure-mcp")
869
+
807
870
  def test_rate_limit_error_is_typed(self):
808
871
  def transport(method, path, headers, body, query, timeout):
809
872
  return 429, {"Retry-After": "60"}, {"code": "rate_limited", "message": "Too many requests."}
@@ -994,6 +1057,9 @@ class StacklinkClientTests(unittest.TestCase):
994
1057
  self.assertEqual(run.response["result"]["stdout"], "ready")
995
1058
  self.assertEqual(run.response["execution"], run.response["executionLog"])
996
1059
  self.assertEqual(run.response["result"], run.response["execution"]["result"])
1060
+ upload_id = calls[2][2].pop("uploadId")
1061
+ self.assertIsInstance(upload_id, str)
1062
+ self.assertEqual(len(upload_id), 36)
997
1063
  self.assertEqual(calls, [
998
1064
  ("GET", "/v1/sessions/session-1/bootstrap", None, {}),
999
1065
  ("POST", "/v1/workbench/sessions", {
@@ -1009,7 +1075,7 @@ class StacklinkClientTests(unittest.TestCase):
1009
1075
  "metadata": {},
1010
1076
  "contentType": "text/plain",
1011
1077
  }, {}),
1012
- ("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1"}),
1078
+ ("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1", "limit": 100}),
1013
1079
  ("POST", "/v1/workbench/run", {
1014
1080
  "language": "python",
1015
1081
  "code": "print('ready')",
File without changes
File without changes
File without changes