stacklink-sdk 0.3.19__tar.gz → 0.6.2__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 (45) hide show
  1. {stacklink_sdk-0.3.19/stacklink_sdk.egg-info → stacklink_sdk-0.6.2}/PKG-INFO +28 -1
  2. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/README.md +27 -0
  3. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/pyproject.toml +1 -1
  4. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/bots.py +31 -5
  5. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/client.py +11 -11
  6. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/errors.py +4 -3
  7. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/mcp.py +4 -1
  8. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/sessions.py +6 -2
  9. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/workbench.py +124 -44
  10. stacklink_sdk-0.6.2/stacklink/workflows.py +129 -0
  11. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2/stacklink_sdk.egg-info}/PKG-INFO +28 -1
  12. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/SOURCES.txt +2 -0
  13. stacklink_sdk-0.6.2/tests/test_amazon_seller_provider.py +38 -0
  14. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_client.py +172 -2
  15. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/LICENSE +0 -0
  16. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/setup.cfg +0 -0
  17. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/__init__.py +0 -0
  18. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/api_keys.py +0 -0
  19. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/approvals.py +0 -0
  20. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/artifacts.py +0 -0
  21. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/auth_configs.py +0 -0
  22. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/billing.py +0 -0
  23. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/claude_agent.py +0 -0
  24. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/connected_accounts.py +0 -0
  25. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/executions.py +0 -0
  26. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/knowledge.py +0 -0
  27. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/logs.py +0 -0
  28. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/memory.py +0 -0
  29. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/openai_agents.py +0 -0
  30. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/openai_memory.py +0 -0
  31. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/projects.py +0 -0
  32. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/providers.py +0 -0
  33. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/tools.py +0 -0
  34. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/triggers.py +0 -0
  35. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/types.py +0 -0
  36. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/webhooks.py +0 -0
  37. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/x_chat.py +0 -0
  38. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
  39. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/requires.txt +0 -0
  40. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/top_level.txt +0 -0
  41. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_google_docs_provider.py +0 -0
  42. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_google_drive_provider.py +0 -0
  43. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_google_sheets_provider.py +0 -0
  44. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_openai_memory.py +0 -0
  45. {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/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.6.2
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
@@ -128,6 +128,33 @@ The async client provides the same operations with `await` and `async for`.
128
128
 
129
129
  Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
130
130
 
131
+ ## Workflows (0.6.2)
132
+
133
+ Use `https://api.stacklink.in` for production.
134
+ Workflow trigger changes now follow draft → publish. `workflows.triggers(id)`
135
+ returns the draft `revision`, draft `triggers`, and active `liveTriggers`.
136
+ Pass the required `expected_revision` keyword to `save_trigger` or
137
+ `delete_trigger`, then publish the returned revision to apply the change.
138
+ A stale revision returns 409; saving a trigger no longer activates it immediately.
139
+ The async client uses the same lifecycle.
140
+
141
+ Bot steps, child workflows, For each and Bot attachments accept `"live"` in place
142
+ of a numeric revision/version. New runs resolve live references once and retain
143
+ that setup through waits, retries and every item. Existing numeric pins remain
144
+ supported. Pausing blocks new starts; publishing does not resume a paused workflow.
145
+ The [Workflows guide](https://docs.stacklink.in/docs/workflows) covers definitions,
146
+ permissions, stable job keys and recovery.
147
+
148
+ Your Python backend can issue customer editor access through
149
+ `workflows.issue_embedded_access(customer_id=..., user_id=..., origin=..., role=...)`.
150
+ Derive those values from your authenticated application session. Mount the hosted
151
+ editor in the frontend with the separate `@stacklink/workflow-builder` JavaScript
152
+ package; never expose the project key. `with_embedded_access(project_id, access_token)`
153
+ provides person-scoped lists, approvals and analytics. `for_customer(customer_id)`
154
+ is for trusted backend administration. The async client supports the same methods.
155
+ See the [embedded guide](https://docs.stacklink.in/docs/workflows#embed-the-builder-in-your-application)
156
+ for the complete lifecycle.
157
+
131
158
  ## Shared approvals (0.3.3)
132
159
 
133
160
  `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
@@ -107,6 +107,33 @@ The async client provides the same operations with `await` and `async for`.
107
107
 
108
108
  Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
109
109
 
110
+ ## Workflows (0.6.2)
111
+
112
+ Use `https://api.stacklink.in` for production.
113
+ Workflow trigger changes now follow draft → publish. `workflows.triggers(id)`
114
+ returns the draft `revision`, draft `triggers`, and active `liveTriggers`.
115
+ Pass the required `expected_revision` keyword to `save_trigger` or
116
+ `delete_trigger`, then publish the returned revision to apply the change.
117
+ A stale revision returns 409; saving a trigger no longer activates it immediately.
118
+ The async client uses the same lifecycle.
119
+
120
+ Bot steps, child workflows, For each and Bot attachments accept `"live"` in place
121
+ of a numeric revision/version. New runs resolve live references once and retain
122
+ that setup through waits, retries and every item. Existing numeric pins remain
123
+ supported. Pausing blocks new starts; publishing does not resume a paused workflow.
124
+ The [Workflows guide](https://docs.stacklink.in/docs/workflows) covers definitions,
125
+ permissions, stable job keys and recovery.
126
+
127
+ Your Python backend can issue customer editor access through
128
+ `workflows.issue_embedded_access(customer_id=..., user_id=..., origin=..., role=...)`.
129
+ Derive those values from your authenticated application session. Mount the hosted
130
+ editor in the frontend with the separate `@stacklink/workflow-builder` JavaScript
131
+ package; never expose the project key. `with_embedded_access(project_id, access_token)`
132
+ provides person-scoped lists, approvals and analytics. `for_customer(customer_id)`
133
+ is for trusted backend administration. The async client supports the same methods.
134
+ See the [embedded guide](https://docs.stacklink.in/docs/workflows#embed-the-builder-in-your-application)
135
+ for the complete lifecycle.
136
+
110
137
  ## Shared approvals (0.3.3)
111
138
 
112
139
  `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
@@ -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.6.2"
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]
@@ -216,8 +238,8 @@ class BotsResource:
216
238
  def retry_delivery(self, bot_id: str, run_id: str, delivery_id: str):
217
239
  return self.client.request("POST", self._path(bot_id, run_id) + "/deliveries/retry", json_body={"deliveryId": delivery_id})
218
240
 
219
- def list(self):
220
- return self.client.request("GET", "/v1/bots")
241
+ def list(self, *, cursor: str | None = None, search: str | None = None, limit: int = 200):
242
+ return self.client.request("GET", "/v1/bots", query={"limit": limit, **({"cursor": cursor} if cursor else {}), **({"search": search} if search else {})})
221
243
 
222
244
  def delete(self, bot_id: str):
223
245
  return self.client.request("DELETE", self._path(bot_id))
@@ -253,6 +275,10 @@ class BotsResource:
253
275
  def get_run(self, bot_id: str, run_id: str):
254
276
  return self.client.request("GET", self._path(bot_id, run_id))
255
277
 
278
+ def continue_run(self, bot_id: str, run_id: str):
279
+ """Continue the same saved funding pause after its funding is resolved."""
280
+ return self.client.request("POST", self._path(bot_id, run_id) + "/continue", json_body={})
281
+
256
282
  def wait(self, bot_id: str, run_id: str, *, total_timeout_ms: int = 300_000):
257
283
  """Return the finished run, or a run waiting for a person's approval or connection."""
258
284
  if total_timeout_ms <= 0:
@@ -263,7 +289,7 @@ class BotsResource:
263
289
  deadline = time.monotonic() + total_timeout_ms / 1000
264
290
  while time.monotonic() < deadline:
265
291
  run = self.get_run(bot_id, run_id)
266
- if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
292
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection") or (run.get("waitingOperation") or {}).get("kind") == "funding":
267
293
  return run
268
294
  time.sleep(min(1, max(0, deadline - time.monotonic())))
269
295
  raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
@@ -272,7 +298,7 @@ class BotsResource:
272
298
  deadline = time.monotonic() + total_timeout_ms / 1000
273
299
  while time.monotonic() < deadline:
274
300
  run = await self.get_run(bot_id, run_id)
275
- if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
301
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection") or (run.get("waitingOperation") or {}).get("kind") == "funding":
276
302
  return run
277
303
  await asyncio.sleep(min(1, max(0, deadline - time.monotonic())))
278
304
  raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
@@ -20,6 +20,7 @@ from .artifacts import ArtifactsResource
20
20
  from .auth_configs import AuthConfigsResource
21
21
  from .billing import BillingResource
22
22
  from .bots import BotsResource
23
+ from .workflows import WorkflowsResource
23
24
  from .approvals import ApprovalsResource
24
25
  from .connected_accounts import ConnectedAccountsResource
25
26
  from .errors import RateLimitError, StacklinkError
@@ -37,7 +38,7 @@ from .types import Json, Transport
37
38
  from .webhooks import WebhooksResource
38
39
  from .workbench import WorkbenchResource
39
40
 
40
- STACKLINK_PYTHON_SDK_VERSION = "0.3.19"
41
+ STACKLINK_PYTHON_SDK_VERSION = "0.6.2"
41
42
  STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
42
43
 
43
44
 
@@ -54,6 +55,7 @@ class Stacklink:
54
55
  max_attempts: int = 3,
55
56
  http_client: httpx.Client | None = None,
56
57
  ):
58
+ self.workflows = WorkflowsResource(self)
57
59
  self.bots = BotsResource(self)
58
60
  self.approvals = ApprovalsResource(self)
59
61
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
@@ -356,18 +358,12 @@ class Stacklink:
356
358
  "user-agent": STACKLINK_PYTHON_USER_AGENT,
357
359
  "x-stacklink-client-channel": "python_sdk",
358
360
  "x-stacklink-client-name": "stacklink-python",
361
+ "x-stacklink-workbench-protocol": "2",
359
362
  "x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
360
363
  }
361
364
 
362
365
  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)
366
+ return _handle_response(status, headers, body)
371
367
 
372
368
  def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
373
369
  event_id = None
@@ -425,6 +421,7 @@ class AsyncStacklink:
425
421
  max_attempts: int = 3,
426
422
  http_client: httpx.AsyncClient | None = None,
427
423
  ):
424
+ self.workflows = WorkflowsResource(self)
428
425
  self.bots = BotsResource(self, async_mode=True)
429
426
  self.approvals = ApprovalsResource(self, async_mode=True)
430
427
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
@@ -671,6 +668,7 @@ class AsyncStacklink:
671
668
  "user-agent": STACKLINK_PYTHON_USER_AGENT,
672
669
  "x-stacklink-client-channel": "python_sdk",
673
670
  "x-stacklink-client-name": "stacklink-python",
671
+ "x-stacklink-workbench-protocol": "2",
674
672
  "x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
675
673
  }
676
674
 
@@ -874,9 +872,11 @@ def _handle_response(status: int, headers: Mapping[str, str], body: Json) -> Jso
874
872
  message = body.get("message") if isinstance(body, dict) else None
875
873
  code = body.get("code") if isinstance(body, dict) else None
876
874
  details = body.get("details") if isinstance(body, dict) else None
875
+ failure_id = body.get("failureId") if isinstance(body, dict) else None
876
+ failure_id = failure_id or headers.get("x-stacklink-failure-id")
877
877
  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)
878
+ raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
879
+ raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
880
880
 
881
881
 
882
882
  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")
@@ -134,8 +134,8 @@ class Session:
134
134
  def get_contracts(self, contract_refs: list[str]):
135
135
  return self.client.sessions.get_contracts(self.id, contract_refs)
136
136
 
137
- def authorize(self, connection_ref: str, *, metadata: dict[str, Any] | None = None):
138
- return self.client.sessions.authorize(self.id, connection_ref, metadata=metadata)
137
+ def authorize(self, connection_ref: str, *, metadata: dict[str, Any] | None = None, amazon_seller_region: str | None = None):
138
+ return self.client.sessions.authorize(self.id, connection_ref, metadata=metadata, amazon_seller_region=amazon_seller_region)
139
139
 
140
140
  def execute(
141
141
  self,
@@ -256,12 +256,16 @@ class SessionsResource:
256
256
  connection_ref: str,
257
257
  *,
258
258
  metadata: dict[str, Any] | None = None,
259
+ amazon_seller_region: str | None = None,
259
260
  ):
261
+ if amazon_seller_region is not None and amazon_seller_region not in ("NA", "EU", "FE"):
262
+ raise ValueError("amazon_seller_region must be NA, EU or FE")
260
263
  return self.client.request(
261
264
  "POST",
262
265
  f"/v1/sessions/{session_id}/connections/authorize",
263
266
  json_body={
264
267
  "connectionRef": connection_ref,
268
+ **({"amazonSellerRegion": amazon_seller_region} if amazon_seller_region is not None else {}),
265
269
  "metadata": dict(metadata or {}),
266
270
  },
267
271
  )
@@ -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
@@ -529,10 +549,15 @@ class WorkbenchExecutionResponse(TypedDict, total=False):
529
549
  artifact: WorkbenchArtifact
530
550
 
531
551
 
552
+ def _execution_session_id(response: Mapping[str, Any], fallback: str | None) -> str | None:
553
+ workspace = response.get("workbenchSession")
554
+ return workspace["platformSessionId"] if isinstance(workspace, dict) and isinstance(workspace.get("platformSessionId"), str) else fallback
555
+
556
+
532
557
  class WorkbenchRun:
533
558
  def __init__(self, client, platform_session_id: str, response: WorkbenchExecutionResponse):
534
559
  self.client = client
535
- self.platform_session_id = platform_session_id
560
+ self.platform_session_id = _execution_session_id(response, platform_session_id)
536
561
  self.response = response
537
562
  self.execution_id = str(response["executionId"])
538
563
  self.execution_kind = str(response["executionKind"])
@@ -631,7 +656,7 @@ def _wait_for_workbench_response(
631
656
  request: Mapping[str, Any],
632
657
  accepted: WorkbenchExecutionResponse,
633
658
  ) -> WorkbenchExecutionResponse:
634
- session_id = request.get("sessionId")
659
+ session_id = _execution_session_id(accepted, request.get("sessionId"))
635
660
  execution_id = accepted.get("executionId")
636
661
  execution_kind = accepted.get("executionKind")
637
662
  if (
@@ -691,11 +716,11 @@ class WorkbenchFiles:
691
716
  def __init__(self, sandbox: "SessionSandbox"):
692
717
  self.sandbox = sandbox
693
718
 
694
- def list(self) -> WorkbenchFileListResponse:
719
+ def list(self, *, cursor: str | None = None, limit: int = 100) -> WorkbenchFileListResponse:
695
720
  return cast(WorkbenchFileListResponse, self.sandbox.client.request(
696
721
  "GET",
697
722
  f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
698
- query={"sessionId": self.sandbox.platform_session_id},
723
+ query={"sessionId": self.sandbox.platform_session_id, "cursor": cursor, "limit": limit},
699
724
  ))
700
725
 
701
726
  def read(self, path: str) -> WorkbenchFileResponse:
@@ -712,33 +737,42 @@ class WorkbenchFiles:
712
737
  *,
713
738
  content_type: str | None = None,
714
739
  metadata: Mapping[str, Any] | None = None,
740
+ upload_id: str | None = None,
715
741
  ) -> WorkbenchFileResponse:
716
742
  raw = content.encode("utf-8") if isinstance(content, str) else content
717
743
  body = {
718
744
  "sessionId": self.sandbox.platform_session_id,
719
745
  "path": path,
746
+ "uploadId": upload_id or str(uuid4()),
720
747
  "contentBase64": base64.b64encode(raw).decode("ascii"),
721
748
  "metadata": dict(metadata or {}),
722
749
  }
723
750
  if content_type is not None:
724
751
  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
- ))
752
+ workspace_id = self.sandbox.ensure_session_id()
753
+ try:
754
+ result = self.sandbox.client.request("PUT", f"/v1/workbench/sessions/{workspace_id}/files", json_body=body)
755
+ return cast(WorkbenchFileResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, workspace_id, result))
756
+ except StacklinkError as error:
757
+ details = error.details if isinstance(error.details, dict) else {}
758
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
759
+ details={**details, "uploadId": body["uploadId"], "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
760
+ headers=error.headers, failure_id=error.failure_id) from error
761
+
762
+ def prepare_upload(self, path: str, *, content_type: str, size_bytes: int, upload_id: str | None = None) -> WorkbenchFileUploadPrepareResponse:
763
+ upload_id = upload_id or str(uuid4())
764
+ workspace_id = self.sandbox.ensure_session_id()
765
+ try:
766
+ return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
767
+ "POST", f"/v1/workbench/sessions/{workspace_id}/files/upload-url",
768
+ json_body={"sessionId": self.sandbox.platform_session_id, "path": path,
769
+ "contentType": content_type, "sizeBytes": size_bytes, "uploadId": upload_id},
770
+ ))
771
+ except StacklinkError as error:
772
+ details = error.details if isinstance(error.details, dict) else {}
773
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
774
+ details={**details, "uploadId": upload_id, "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
775
+ headers=error.headers, failure_id=error.failure_id) from error
742
776
 
743
777
  def finalize_upload(
744
778
  self,
@@ -746,7 +780,7 @@ class WorkbenchFiles:
746
780
  *,
747
781
  metadata: Mapping[str, Any] | None = None,
748
782
  ) -> WorkbenchFileMutationResponse:
749
- return cast(WorkbenchFileMutationResponse, self.sandbox.client.request(
783
+ result = self.sandbox.client.request(
750
784
  "POST",
751
785
  f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/finalize-upload",
752
786
  json_body={
@@ -754,21 +788,31 @@ class WorkbenchFiles:
754
788
  "uploadId": upload_id,
755
789
  "metadata": dict(metadata or {}),
756
790
  },
757
- ))
791
+ )
792
+ return cast(WorkbenchFileMutationResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
758
793
 
759
794
  def upload_large(
760
795
  self,
761
796
  path: str,
762
- content: str | bytes,
797
+ content: str | bytes | BinaryIO,
763
798
  *,
764
799
  content_type: str | None = None,
765
800
  metadata: Mapping[str, Any] | None = None,
801
+ transfer_timeout_seconds: float = 300.0,
802
+ upload_id: str | None = None,
766
803
  ) -> WorkbenchFileMutationResponse:
767
804
  raw = content.encode("utf-8") if isinstance(content, str) else content
768
805
  resolved_content_type = content_type or (
769
806
  "text/plain; charset=utf-8" if isinstance(content, str) else "application/octet-stream"
770
807
  )
771
- prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=len(raw))
808
+ if isinstance(raw, bytes):
809
+ size_bytes = len(raw)
810
+ else:
811
+ position = raw.tell()
812
+ raw.seek(0, 2)
813
+ size_bytes = raw.tell() - position
814
+ raw.seek(position)
815
+ prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=size_bytes, upload_id=upload_id)
772
816
  upload = prepared.get("upload") if isinstance(prepared, dict) else None
773
817
  if (
774
818
  not isinstance(upload, dict)
@@ -777,13 +821,16 @@ class WorkbenchFiles:
777
821
  ):
778
822
  raise StacklinkError("Stacklink did not return a signed Workbench upload URL.")
779
823
  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()})
824
+ 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
825
  try:
782
- with urlopen(request, timeout=self.sandbox.client.timeout) as response:
826
+ with urlopen(request, timeout=transfer_timeout_seconds) as response:
783
827
  if response.status >= 400:
784
828
  raise StacklinkError(f"Workbench file upload failed with {response.status}.")
785
829
  except HTTPError as error:
786
- raise StacklinkError(f"Workbench file upload failed with {error.code}.") from error
830
+ if error.code != 412:
831
+ 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
832
+ except (TimeoutError, OSError) as error:
833
+ raise StacklinkError("Upload outcome is unknown. Finalize the same uploadId to reconcile.", code="workbench_upload_outcome_unknown", details={"uploadId": upload["uploadId"]}) from error
787
834
  return self.finalize_upload(
788
835
  upload["uploadId"],
789
836
  metadata=metadata,
@@ -799,7 +846,7 @@ class WorkbenchFiles:
799
846
  if not isinstance(download_url, str):
800
847
  raise StacklinkError("Stacklink did not return a signed Workbench download URL.")
801
848
  try:
802
- with urlopen(Request(download_url, method="GET"), timeout=self.sandbox.client.timeout) as response:
849
+ with urlopen(Request(download_url, method="GET", headers=signed.get("requiredHeaders", {})), timeout=300.0) as response:
803
850
  return response.read()
804
851
  except HTTPError as error:
805
852
  raise StacklinkError(f"Workbench file download failed with {error.code}.") from error
@@ -1010,18 +1057,27 @@ class WorkbenchArtifacts:
1010
1057
  label: str | None = None,
1011
1058
  content_type: str | None = None,
1012
1059
  metadata: Mapping[str, Any] | None = None,
1060
+ artifact_ref: str | None = None,
1013
1061
  ) -> WorkbenchArtifactResponse:
1014
1062
  body: dict[str, Any] = {
1015
1063
  "sessionId": self.sandbox.platform_session_id,
1016
1064
  "workbenchSessionId": self.sandbox.ensure_session_id(),
1017
1065
  "path": path,
1066
+ "artifactRef": artifact_ref or f"wba_{uuid4()}",
1018
1067
  "metadata": dict(metadata or {}),
1019
1068
  }
1020
1069
  if label is not None:
1021
1070
  body["label"] = label
1022
1071
  if content_type is not None:
1023
1072
  body["contentType"] = content_type
1024
- return cast(WorkbenchArtifactResponse, self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body))
1073
+ try:
1074
+ result = self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body)
1075
+ return cast(WorkbenchArtifactResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
1076
+ except StacklinkError as error:
1077
+ details = error.details if isinstance(error.details, dict) else {}
1078
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
1079
+ details={**details, "artifactRef": body["artifactRef"], "workbenchSessionId": body["workbenchSessionId"], "sessionId": body["sessionId"]},
1080
+ headers=error.headers, failure_id=error.failure_id) from error
1025
1081
 
1026
1082
  def download_url(self, artifact_id: str) -> WorkbenchArtifactDownloadResponse:
1027
1083
  return cast(WorkbenchArtifactDownloadResponse, self.sandbox.client.workbench.artifact_download_url(artifact_id))
@@ -1089,10 +1145,11 @@ class SessionSandbox:
1089
1145
  "workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
1090
1146
  "operation": "run_code",
1091
1147
  }
1092
- response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/run", json_body=request)
1148
+ response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/run", json_body=request, idempotency_key=request.get("idempotencyKey"))
1093
1149
  if body.get("background") is not True:
1094
1150
  response = _wait_for_workbench_response(self.client, request, response)
1095
- self.workbench_session_id = str(response["workbenchSession"]["id"])
1151
+ if _execution_session_id(response, self.platform_session_id) == self.platform_session_id:
1152
+ self.workbench_session_id = str(response["workbenchSession"]["id"])
1096
1153
  return WorkbenchRun(self.client, self.platform_session_id, response)
1097
1154
 
1098
1155
  def bash(self, body: WorkbenchBashInput) -> WorkbenchRun:
@@ -1101,10 +1158,11 @@ class SessionSandbox:
1101
1158
  "sessionId": self.platform_session_id,
1102
1159
  "workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
1103
1160
  }
1104
- response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/bash", json_body=request)
1161
+ response: WorkbenchExecutionResponse = self.client.request("POST", "/v1/workbench/bash", json_body=request, idempotency_key=request.get("idempotencyKey"))
1105
1162
  if body.get("background") is not True:
1106
1163
  response = _wait_for_workbench_response(self.client, request, response)
1107
- self.workbench_session_id = str(response["workbenchSession"]["id"])
1164
+ if _execution_session_id(response, self.platform_session_id) == self.platform_session_id:
1165
+ self.workbench_session_id = str(response["workbenchSession"]["id"])
1108
1166
  return WorkbenchRun(self.client, self.platform_session_id, response)
1109
1167
 
1110
1168
  def destroy(self) -> WorkbenchSessionDestroyResponse:
@@ -1133,13 +1191,17 @@ class WorkbenchResource:
1133
1191
  return self.client.request("GET", "/v1/workbench/models")
1134
1192
 
1135
1193
  def run(self, body: dict[str, Any]):
1136
- response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body))
1194
+ response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body, idempotency_key=body.get("idempotencyKey")))
1195
+ if isinstance(response.get("operation"), dict):
1196
+ operation = response["operation"]
1197
+ completed = self.wait_operation(body["sessionId"], operation["workbenchSessionId"], operation["operationId"])
1198
+ return {**{key: value for key, value in response.items() if key != "operation"}, **completed}
1137
1199
  if body.get("operation") == "run_code" and body.get("background") is not True:
1138
1200
  return _wait_for_workbench_response(self.client, body, response)
1139
1201
  return response
1140
1202
 
1141
1203
  def bash(self, body: dict[str, Any]):
1142
- response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/bash", json_body=body))
1204
+ response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/bash", json_body=body, idempotency_key=body.get("idempotencyKey")))
1143
1205
  if body.get("background") is not True:
1144
1206
  return _wait_for_workbench_response(self.client, body, response)
1145
1207
  return response
@@ -1162,12 +1224,30 @@ class WorkbenchResource:
1162
1224
  "operation": "destroy",
1163
1225
  })
1164
1226
 
1227
+ def operation(self, session_id: str, workbench_session_id: str, operation_id: str):
1228
+ return self.client.request("GET", f"/v1/workbench/sessions/{workbench_session_id}/operations/{operation_id}", query={"sessionId": session_id})
1229
+
1230
+ def wait_operation(self, session_id: str, workbench_session_id: str, operation_id: str, *, timeout_seconds: float = 300.0):
1231
+ deadline = time.monotonic() + timeout_seconds
1232
+ while time.monotonic() < deadline:
1233
+ try:
1234
+ value = self.operation(session_id, workbench_session_id, operation_id)["operation"]
1235
+ except StacklinkError as error:
1236
+ 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
1237
+ if value["status"] == "completed":
1238
+ return value["result"]
1239
+ if value["status"] == "failed":
1240
+ raise StacklinkError("Workbench operation needs attention.", code="workbench_operation_failed", details=value)
1241
+ time.sleep(min(max(0.1, value.get("retryAfterMs", 1000) / 1000), max(0, deadline - time.monotonic())))
1242
+ 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})
1243
+
1244
+ def finish_operation(self, session_id: str, workbench_session_id: str, result):
1245
+ operation = result.get("operation") if isinstance(result, dict) else None
1246
+ return self.wait_operation(session_id, workbench_session_id, operation["operationId"]) if isinstance(operation, dict) else result
1247
+
1165
1248
  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
- ))
1249
+ result = self.client.request("DELETE", f"/v1/workbench/sessions/{workbench_session_id}", query={"sessionId": session_id})
1250
+ return cast(WorkbenchSessionDeleteReceipt, self.finish_operation(session_id, workbench_session_id, result))
1171
1251
 
1172
1252
  def sessions(self, session_id: str):
1173
1253
  return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
@@ -0,0 +1,129 @@
1
+ """Durable workflow definitions and runs. Reuse job_key after a timeout."""
2
+ from typing import Any
3
+ from urllib.parse import quote
4
+
5
+
6
+ class WorkflowsResource:
7
+ def __init__(self, client, *, customer_id=None, project_id=None, access_token=None):
8
+ self.client = client
9
+ self.customer_id = customer_id
10
+ self.project_id = project_id
11
+ self.access_token = access_token
12
+
13
+ def for_customer(self, customer_id: str):
14
+ return WorkflowsResource(self.client, customer_id=customer_id)
15
+
16
+ def with_embedded_access(self, project_id: str, access_token: str):
17
+ return WorkflowsResource(self.client, project_id=project_id, access_token=access_token)
18
+
19
+ def _request(self, method, path, **options):
20
+ headers = dict(options.pop('headers', {}) or {})
21
+ if self.customer_id:
22
+ headers['X-Stacklink-Customer'] = self.customer_id
23
+ if self.access_token:
24
+ headers['authorization'] = 'Bearer ' + self.access_token
25
+ return self.client.request(method, path, headers=headers, **options)
26
+
27
+ def _path(self, workflow_id: str | None = None) -> str:
28
+ return ('/embedded/api/projects/' + quote(self.project_id, safe='') + '/workflows' if self.project_id else '/v1/workflows') + ('/' + quote(workflow_id, safe='') if workflow_id else '')
29
+
30
+ def list(self, *, cursor: str | None = None, search: str | None = None, limit: int = 200):
31
+ return self._request('GET', self._path(), query={'limit': limit, **({'cursor': cursor} if cursor else {}), **({'search': search} if search else {})})
32
+
33
+ def create(self, name: str, definition: dict[str, Any], *, source: str = 'code', description: str = '', deployment: dict[str, str] | None = None):
34
+ return self._request('POST', self._path(), json_body={'name': name, 'definition': definition, 'source': source, 'description': description, **({'deployment': deployment} if deployment is not None else {})})
35
+
36
+ def get(self, workflow_id: str):
37
+ return self._request('GET', self._path(workflow_id))
38
+
39
+ def update(self, workflow_id: str, revision: int, **changes):
40
+ return self._request('PATCH', self._path(workflow_id), json_body={**changes, 'revision': revision})
41
+
42
+ def validate(self, definition: dict[str, Any]):
43
+ return self._request('POST', self._path() + '/validate', json_body={'definition': definition})
44
+
45
+ def publish(self, workflow_id: str, revision: int):
46
+ return self._request('POST', self._path(workflow_id) + '/publish', json_body={'revision': revision})
47
+
48
+ def set_state(self, workflow_id: str, paused: bool):
49
+ return self._request('POST', self._path(workflow_id) + '/state', json_body={'paused': paused})
50
+
51
+ def versions(self, workflow_id: str):
52
+ return self._request('GET', self._path(workflow_id) + '/versions')
53
+
54
+ def run(self, workflow_id: str, session_id: str, job_key: str, *, input: dict[str, Any] | None = None, **options):
55
+ return self._request('POST', self._path(workflow_id) + '/runs', json_body={**options, 'sessionId': session_id, 'jobKey': job_key, 'input': input or {}}, idempotency_key=job_key)
56
+
57
+ def runs(self, workflow_id: str, *, cursor: str | None = None, limit: int = 50):
58
+ return self._request('GET', self._path(workflow_id) + '/runs', query={'limit': limit, **({'cursor': cursor} if cursor else {})})
59
+
60
+ def get_run(self, workflow_id: str, run_id: str):
61
+ return self._request('GET', self._path(workflow_id) + '/runs/' + quote(run_id, safe=''))
62
+
63
+ def events(self, workflow_id: str, run_id: str, *, before: str | None = None, limit: int = 200):
64
+ return self._request('GET', self._path(workflow_id) + '/runs/' + quote(run_id, safe='') + '/events', query={'limit': limit, **({'before': before} if before else {})})
65
+
66
+ def action(self, workflow_id: str, run_id: str, action: str, **options):
67
+ return self._request('POST', self._path(workflow_id) + '/runs/' + quote(run_id, safe='') + '/actions', json_body={**options, 'action': action})
68
+
69
+ def triggers(self, workflow_id: str):
70
+ return self._request('GET', self._path(workflow_id) + '/triggers')
71
+
72
+ def save_trigger(self, workflow_id: str, configuration: dict[str, Any], *, expected_revision: int):
73
+ """Save a draft trigger. Publish the returned revision to activate it."""
74
+ return self._request('POST', self._path(workflow_id) + '/triggers', json_body={**configuration, 'expectedRevision': expected_revision})
75
+
76
+ def delete_trigger(self, workflow_id: str, trigger_id: str, *, expected_revision: int):
77
+ return self._request('DELETE', self._path(workflow_id) + '/triggers/' + quote(trigger_id, safe=''), query={'expectedRevision': expected_revision})
78
+
79
+ def invoke_trigger(self, workflow_id: str, trigger_id: str, request_id: str, input: dict[str, Any]):
80
+ return self._request('POST', self._path(workflow_id) + '/triggers/' + quote(trigger_id, safe='') + '/invoke', json_body={'requestId': request_id, 'input': input}, idempotency_key=request_id)
81
+
82
+ def preview_schedule(self, cron: str, timezone: str = 'UTC'):
83
+ return self._request('POST', self._path() + '/schedule/preview', json_body={'cron': cron, 'timezone': timezone})
84
+
85
+ def suggest(self, workflow_id: str, description: str, revision: int, session_id: str):
86
+ return self._request('POST', self._path(workflow_id) + '/suggest', json_body={'description': description, 'revision': revision, 'sessionId': session_id}, timeout_ms=60_000)
87
+
88
+
89
+ def issue_embedded_access(self, customer_id: str, user_id: str, origin: str, role: str, *, decide_approvals: bool = False, view_usage: bool = False, workflow_id: str | None = None, customer_name: str | None = None, user_name: str | None = None):
90
+ return self.client.request('POST', '/v1/workflows/embed/access', json_body={
91
+ 'customerId': customer_id, 'userId': user_id, 'origin': origin, 'role': role,
92
+ 'decideApprovals': decide_approvals, 'viewUsage': view_usage,
93
+ **({'workflowId': workflow_id} if workflow_id else {}),
94
+ **({'customerName': customer_name} if customer_name else {}),
95
+ **({'userName': user_name} if user_name else {}),
96
+ })
97
+
98
+ def exchange_embedded_access(self, bootstrap_token: str, origin: str):
99
+ return self.client.request('POST', '/v1/workflows/embed/exchange', json_body={'bootstrapToken': bootstrap_token, 'origin': origin})
100
+
101
+ def embed_settings(self):
102
+ return self._request('GET', self._path() + '/embed')
103
+
104
+ def save_embed_settings(self, revision: int, config: dict):
105
+ return self._request('PUT', self._path() + '/embed', json_body={'revision': revision, 'config': config})
106
+
107
+ def customers(self, **query):
108
+ return self._request('GET', self._path() + '/embed/customers', query=query)
109
+
110
+ def customer(self, customer_id: str):
111
+ return self._request('GET', self._path() + '/embed/customers/' + quote(customer_id, safe=''))
112
+
113
+ def set_customer_automations(self, customer_id: str, enabled: bool):
114
+ return self._request('POST', self._path() + '/embed/customers/' + quote(customer_id, safe='') + '/automations', json_body={'enabled': enabled})
115
+
116
+ def set_customer_person_access(self, customer_id: str, person_id: str, action: str):
117
+ return self._request('POST', self._path() + '/embed/customers/' + quote(customer_id, safe='') + '/people/' + quote(person_id, safe='') + '/access', json_body={'action': action})
118
+
119
+ def analytics(self, *, days: int = 30, customer_id: str | None = None):
120
+ return self._request('GET', self._path() + '/analytics', query={'days': days, **({'customerId': customer_id} if customer_id else {})})
121
+
122
+ def activity(self, **query):
123
+ return self._request('GET', self._path() + '/activity', query=query)
124
+
125
+ def approvals(self, *, view: str = 'inbox', cursor: str | None = None, limit: int = 50):
126
+ return self._request('GET', self._path() + '/approvals', query={'view': view, 'limit': limit, **({'cursor': cursor} if cursor else {})})
127
+
128
+ def decide_approval(self, approval_id: str, decision: dict):
129
+ return self._request('POST', self._path() + '/approvals/' + quote(approval_id, safe='') + '/decide', json_body=decision)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.3.19
3
+ Version: 0.6.2
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
@@ -128,6 +128,33 @@ The async client provides the same operations with `await` and `async for`.
128
128
 
129
129
  Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
130
130
 
131
+ ## Workflows (0.6.2)
132
+
133
+ Use `https://api.stacklink.in` for production.
134
+ Workflow trigger changes now follow draft → publish. `workflows.triggers(id)`
135
+ returns the draft `revision`, draft `triggers`, and active `liveTriggers`.
136
+ Pass the required `expected_revision` keyword to `save_trigger` or
137
+ `delete_trigger`, then publish the returned revision to apply the change.
138
+ A stale revision returns 409; saving a trigger no longer activates it immediately.
139
+ The async client uses the same lifecycle.
140
+
141
+ Bot steps, child workflows, For each and Bot attachments accept `"live"` in place
142
+ of a numeric revision/version. New runs resolve live references once and retain
143
+ that setup through waits, retries and every item. Existing numeric pins remain
144
+ supported. Pausing blocks new starts; publishing does not resume a paused workflow.
145
+ The [Workflows guide](https://docs.stacklink.in/docs/workflows) covers definitions,
146
+ permissions, stable job keys and recovery.
147
+
148
+ Your Python backend can issue customer editor access through
149
+ `workflows.issue_embedded_access(customer_id=..., user_id=..., origin=..., role=...)`.
150
+ Derive those values from your authenticated application session. Mount the hosted
151
+ editor in the frontend with the separate `@stacklink/workflow-builder` JavaScript
152
+ package; never expose the project key. `with_embedded_access(project_id, access_token)`
153
+ provides person-scoped lists, approvals and analytics. `for_customer(customer_id)`
154
+ is for trusted backend administration. The async client supports the same methods.
155
+ See the [embedded guide](https://docs.stacklink.in/docs/workflows#embed-the-builder-in-your-application)
156
+ for the complete lifecycle.
157
+
131
158
  ## Shared approvals (0.3.3)
132
159
 
133
160
  `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
@@ -27,12 +27,14 @@ stacklink/triggers.py
27
27
  stacklink/types.py
28
28
  stacklink/webhooks.py
29
29
  stacklink/workbench.py
30
+ stacklink/workflows.py
30
31
  stacklink/x_chat.py
31
32
  stacklink_sdk.egg-info/PKG-INFO
32
33
  stacklink_sdk.egg-info/SOURCES.txt
33
34
  stacklink_sdk.egg-info/dependency_links.txt
34
35
  stacklink_sdk.egg-info/requires.txt
35
36
  stacklink_sdk.egg-info/top_level.txt
37
+ tests/test_amazon_seller_provider.py
36
38
  tests/test_client.py
37
39
  tests/test_google_docs_provider.py
38
40
  tests/test_google_drive_provider.py
@@ -0,0 +1,38 @@
1
+ import unittest
2
+
3
+ from stacklink import Stacklink
4
+
5
+
6
+ class AmazonSellerClientTests(unittest.TestCase):
7
+ def test_authorization_uses_the_shared_session_route_and_exact_region(self):
8
+ calls = []
9
+
10
+ def transport(method, path, headers, body, query, timeout):
11
+ calls.append((method, path, body))
12
+ return 200, {}, {"authorization": {"providerKey": "amazon_seller", "status": "pending"}}
13
+
14
+ client = Stacklink(api_key="ppk_test", transport=transport)
15
+ client.sessions.authorize("session-owned", "opaque-ref", amazon_seller_region="EU")
16
+ self.assertEqual(calls, [("POST", "/v1/sessions/session-owned/connections/authorize", {
17
+ "connectionRef": "opaque-ref", "amazonSellerRegion": "EU", "metadata": {}
18
+ })])
19
+ with self.assertRaises(ValueError):
20
+ client.sessions.authorize("session-owned", "opaque-ref", amazon_seller_region="India")
21
+ self.assertEqual(len(calls), 1)
22
+
23
+ def test_execution_uses_existing_tool_route_without_provider_credentials(self):
24
+ calls = []
25
+
26
+ def transport(method, path, headers, body, query, timeout):
27
+ calls.append((method, path, body))
28
+ return 200, {}, {"status": "completed", "output": {"data": {"payload": []}}}
29
+
30
+ client = Stacklink(api_key="ppk_test", transport=transport)
31
+ payload = {"sessionId": "session-owned", "toolKey": "amazon_seller.seller.get_marketplace_participations", "input": {}}
32
+ result = client.tools.execute(payload)
33
+ self.assertEqual(result["status"], "completed")
34
+ self.assertEqual(calls, [("POST", "/v1/tools/execute", payload)])
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -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,114 @@ def stalled_stream_server(*, final_result=False, execution_page=False):
63
63
 
64
64
 
65
65
  class StacklinkClientTests(unittest.TestCase):
66
+ def test_workflow_embedded_access_maps_delegated_permissions(self):
67
+ calls = []
68
+ def transport(method, path, headers, body, query, timeout):
69
+ calls.append((method, path, body))
70
+ return 200, {}, {"bootstrapToken": "one-use", "embedUrl": "https://staging.stacklink.in/embed/workflows/project"}
71
+ client = Stacklink(api_key="host-backend-key", transport=transport)
72
+ client.workflows.issue_embedded_access(
73
+ "fernhill", "maya", "https://acme.example", "viewer",
74
+ decide_approvals=True, view_usage=True, workflow_id="workflow-1",
75
+ customer_name="Fernhill", user_name="Maya",
76
+ )
77
+ self.assertEqual(calls, [("POST", "/v1/workflows/embed/access", {
78
+ "customerId": "fernhill", "userId": "maya", "origin": "https://acme.example", "role": "viewer",
79
+ "decideApprovals": True, "viewUsage": True, "workflowId": "workflow-1",
80
+ "customerName": "Fernhill", "userName": "Maya",
81
+ })])
82
+
83
+ def test_workflow_embedded_context_does_not_change_project_client(self):
84
+ calls = []
85
+ def transport(method, path, headers, body, query, timeout):
86
+ calls.append((path, dict(headers), body))
87
+ return 200, {}, {"workflows": [], "nextCursor": None}
88
+ client = Stacklink(api_key="host-backend-key", transport=transport)
89
+ a = client.workflows.for_customer("fernhill")
90
+ b = client.workflows.for_customer("larkspur")
91
+ a.list()
92
+ b.list()
93
+ client.workflows.list()
94
+ person = client.workflows.with_embedded_access("project-1", "sle_person")
95
+ person.approvals()
96
+ person.decide_approval("task-1", {"kind": "workflow", "value": "approved"})
97
+ self.assertEqual([row[1].get("X-Stacklink-Customer") for row in calls], ["fernhill", "larkspur", None, None, None])
98
+ self.assertEqual(calls[3][0], "/embedded/api/projects/project-1/workflows/approvals")
99
+ self.assertEqual({key.lower(): value for key, value in calls[3][1].items()}["authorization"], "Bearer sle_person")
100
+ self.assertEqual(calls[4][2], {"kind": "workflow", "value": "approved"})
101
+
102
+ def test_workflow_retry_and_trigger_use_stable_request_identity(self):
103
+ calls = []
104
+ def transport(method, path, headers, body, query, timeout):
105
+ calls.append((method, path, headers.get("Idempotency-Key"), body, query))
106
+ if len(calls) == 1:
107
+ return 503, {}, {"message": "Lost acknowledgement"}
108
+ return 200, {}, {"id": "same-run", "runs": [], "nextCursor": None}
109
+ client = Stacklink(api_key="ppk_test", transport=transport)
110
+ with patch("stacklink.client.time.sleep", return_value=None):
111
+ result = client.workflows.run("wf-1", "session-1", "upload-42", input={"recording": "चर्चा"})
112
+ self.assertEqual(result["id"], "same-run")
113
+ self.assertEqual(calls[0], calls[1])
114
+ self.assertEqual(calls[0][2], "upload-42")
115
+ client.workflows.runs("wf-1", cursor="run-1", limit=10)
116
+ self.assertEqual(calls[2][4], {"cursor": "run-1", "limit": 10})
117
+ client.workflows.invoke_trigger("wf-1", "trigger-1", "event-8", {"subject": "Ready"})
118
+ self.assertEqual(calls[3][1:4], ("/v1/workflows/wf-1/triggers/trigger-1/invoke", "event-8", {"requestId": "event-8", "input": {"subject": "Ready"}}))
119
+ client.workflows.suggest("wf-1", "Review findings", 3, "session-1")
120
+ self.assertEqual(calls[4][1], "/v1/workflows/wf-1/suggest")
121
+ self.assertEqual(calls[4][3], {"description": "Review findings", "revision": 3, "sessionId": "session-1"})
122
+ client.workflows.save_trigger("wf-1", {"kind": "webhook"}, expected_revision=3)
123
+ self.assertEqual(calls[5][3], {"kind": "webhook", "expectedRevision": 3})
124
+ client.workflows.delete_trigger("wf-1", "trigger-1", expected_revision=4)
125
+ self.assertEqual(calls[6][4], {"expectedRevision": 4})
126
+
127
+
128
+ def test_workbench_lost_acknowledgement_keeps_upload_and_artifact_identity(self):
129
+ bodies = []
130
+ def transport(method, path, headers, body, query, timeout):
131
+ bodies.append(body)
132
+ return 504, {}, {"code": "outbound_http_timeout", "message": "Lost acknowledgement"}
133
+ client = Stacklink(api_key="test", transport=transport, max_attempts=1)
134
+ sandbox = SessionSandbox(client, "session-1")
135
+ sandbox.workbench_session_id = "workspace-1"
136
+ with self.assertRaises(StacklinkError) as caught:
137
+ sandbox.files.upload("नतीजा.bin", b"result")
138
+ upload_id = caught.exception.details["uploadId"]
139
+ with self.assertRaises(StacklinkError):
140
+ sandbox.files.upload("नतीजा.bin", b"result", upload_id=upload_id)
141
+ self.assertEqual(bodies[0]["uploadId"], bodies[1]["uploadId"])
142
+ with self.assertRaises(StacklinkError) as caught:
143
+ sandbox.artifacts.promote("नतीजा.bin")
144
+ artifact_ref = caught.exception.details["artifactRef"]
145
+ with self.assertRaises(StacklinkError):
146
+ sandbox.artifacts.promote("नतीजा.bin", artifact_ref=artifact_ref)
147
+ self.assertEqual(bodies[2]["artifactRef"], bodies[3]["artifactRef"])
148
+
149
+ def test_workbench_storage_wait_retains_identity_and_never_repeats_deletion(self):
150
+ calls = []
151
+ receipt = {"deleted": True, "databaseDeleted": True, "workbenchSessionId": "workspace-1"}
152
+ def transport(method, path, headers, body, query, timeout):
153
+ calls.append((method, path))
154
+ if method == "DELETE":
155
+ return 202, {}, {"operation": {"operationId": "operation-1"}}
156
+ return 200, {}, {"operation": {"operationId": "operation-1", "status": "completed", "result": receipt}}
157
+ client = Stacklink(api_key="test", transport=transport)
158
+ self.assertEqual(client.workbench.delete_session("session-1", "workspace-1"), receipt)
159
+ self.assertEqual([method for method, _ in calls], ["DELETE", "GET"])
160
+ with self.assertRaises(StacklinkError) as caught:
161
+ client.workbench.wait_operation("session-1", "workspace-1", "operation-1", timeout_seconds=0)
162
+ self.assertEqual(caught.exception.details, {"sessionId": "session-1", "workbenchSessionId": "workspace-1", "operationId": "operation-1"})
163
+ self.assertEqual(len(calls), 2)
164
+
165
+ def test_workbench_storage_observation_failure_keeps_resume_handle(self):
166
+ def transport(method, path, headers, body, query, timeout):
167
+ return 403, {}, {"code": "access_denied", "message": "denied"}
168
+ client = Stacklink(api_key="test", transport=transport)
169
+ with self.assertRaises(StacklinkError) as caught:
170
+ client.workbench.wait_operation("session-1", "workspace-1", "operation-1")
171
+ self.assertEqual(caught.exception.code, "workbench_operation_observation_failed")
172
+ self.assertEqual(caught.exception.details["operationId"], "operation-1")
173
+
66
174
  def test_bot_avatar_recipe_is_preserved_for_sync_and_async_create(self):
67
175
  recipe = {"version": 1, "style": "clay", "seed": "Sales assistant", "tone": "mint"}
68
176
  payloads = []
@@ -97,6 +205,48 @@ class StacklinkClientTests(unittest.TestCase):
97
205
  self.assertEqual(calls[2][2], {'revision': 2})
98
206
 
99
207
 
208
+ def test_workbench_replay_observes_the_original_session(self):
209
+ for operation in ("bash", "run_code"):
210
+ calls = []
211
+ kind = "workbench_bash" if operation == "bash" else "workbench_code"
212
+ def transport(method, path, headers, body, query, timeout):
213
+ calls.append((path, body))
214
+ if path in {"/v1/workbench/bash", "/v1/workbench/run"}:
215
+ return 202, {}, {"executionId": "original-execution", "executionKind": kind, "status": "queued", "idempotency": {"replayed": True}, "workbenchSession": {"id": "workspace", "platformSessionId": "original-session"}}
216
+ self.assertEqual(body["sessionId"], "original-session")
217
+ return 200, {}, {"execution": {"id": "original-execution", "executionKind": kind, "status": "completed", "stdout": "saved result"}}
218
+ client = Stacklink(api_key="ppk_test", transport=transport)
219
+ payload = {"sessionId": "replacement-session", "idempotencyKey": "same-logical-job", "operation": operation, "command": "echo saved", "language": "python", "code": 'print("saved")'}
220
+ result = client.workbench.bash(payload) if operation == "bash" else client.workbench.run(payload)
221
+ self.assertEqual(result["status"], "completed")
222
+ self.assertEqual(len(calls), 2)
223
+ self.assertEqual(calls[0][1]["sessionId"], "replacement-session")
224
+
225
+ def test_replayed_run_keeps_the_replacement_sandbox_separate(self):
226
+ for operation in ("bash", "run_code"):
227
+ controls = []
228
+ def transport(method, path, headers, body, query, timeout):
229
+ if path.endswith("/files"):
230
+ self.assertEqual(query["sessionId"], "replacement-session")
231
+ self.assertEqual(path, "/v1/workbench/sessions/replacement-workspace/files")
232
+ return 200, {}, {"files": []}
233
+ if path.startswith("/v1/executions/"):
234
+ self.assertEqual(body["sessionId"], "original-session")
235
+ controls.append(path)
236
+ return 200, {}, {"execution": {"id": "original-execution", "status": "running"}}
237
+ if path == "/v1/workbench/sessions":
238
+ return 200, {}, {"workbenchSession": {"id": "replacement-workspace", "platformSessionId": "replacement-session"}}
239
+ self.assertEqual(body["sessionId"], "replacement-session")
240
+ self.assertEqual(body["workbenchSessionId"], "replacement-workspace")
241
+ return 202, {}, {"executionId": "original-execution", "executionKind": "workbench_bash" if operation == "bash" else "workbench_code", "status": "running", "workbenchSession": {"id": "original-workspace", "platformSessionId": "original-session"}}
242
+ client = Stacklink(api_key="ppk_test", transport=transport)
243
+ sandbox = SessionSandbox(client, "replacement-session")
244
+ run = sandbox.bash({"command": "echo saved", "idempotencyKey": "same-work", "background": True}) if operation == "bash" else sandbox.run({"language": "python", "code": 'print("saved")', "idempotencyKey": "same-work", "background": True})
245
+ run.wait()
246
+ run.cancel("explicit cancellation")
247
+ sandbox.files.list()
248
+ self.assertEqual(len(controls), 2)
249
+
100
250
  def test_workbench_approval_returns_to_caller_before_decision(self):
101
251
  for operation in ("run_code", "bash"):
102
252
  for admission_status in ("waiting_for_approval", "queued"):
@@ -804,6 +954,23 @@ class StacklinkClientTests(unittest.TestCase):
804
954
  lines.extend(decoder.finish())
805
955
  self.assertEqual(lines, ['data: {"value":"नमस्ते"}', ''])
806
956
 
957
+ def test_failure_reference_survives_http_and_mcp_errors(self):
958
+ from stacklink.mcp import unwrap_tool_result
959
+ for status, body in [(502, {"failureId": "failure-body"}), (429, {})]:
960
+ def transport(method, path, headers, request_body, query, timeout):
961
+ return status, {"x-stacklink-failure-id": "failure-header"}, body
962
+ client = Stacklink(api_key="ppk_test", transport=transport)
963
+ with self.assertRaises(StacklinkError) as caught:
964
+ client.tools.search({"sessionId": "session", "query": "gmail"})
965
+ self.assertEqual(caught.exception.failure_id, body.get("failureId", "failure-header"))
966
+ for response in [
967
+ {"error": {"code": -32603, "data": {"failureId": "failure-mcp"}}},
968
+ {"result": {"isError": True, "structuredContent": {"error": {"failureId": "failure-mcp"}}}},
969
+ ]:
970
+ with self.assertRaises(StacklinkError) as caught:
971
+ unwrap_tool_result(response)
972
+ self.assertEqual(caught.exception.failure_id, "failure-mcp")
973
+
807
974
  def test_rate_limit_error_is_typed(self):
808
975
  def transport(method, path, headers, body, query, timeout):
809
976
  return 429, {"Retry-After": "60"}, {"code": "rate_limited", "message": "Too many requests."}
@@ -994,6 +1161,9 @@ class StacklinkClientTests(unittest.TestCase):
994
1161
  self.assertEqual(run.response["result"]["stdout"], "ready")
995
1162
  self.assertEqual(run.response["execution"], run.response["executionLog"])
996
1163
  self.assertEqual(run.response["result"], run.response["execution"]["result"])
1164
+ upload_id = calls[2][2].pop("uploadId")
1165
+ self.assertIsInstance(upload_id, str)
1166
+ self.assertEqual(len(upload_id), 36)
997
1167
  self.assertEqual(calls, [
998
1168
  ("GET", "/v1/sessions/session-1/bootstrap", None, {}),
999
1169
  ("POST", "/v1/workbench/sessions", {
@@ -1009,7 +1179,7 @@ class StacklinkClientTests(unittest.TestCase):
1009
1179
  "metadata": {},
1010
1180
  "contentType": "text/plain",
1011
1181
  }, {}),
1012
- ("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1"}),
1182
+ ("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1", "limit": 100}),
1013
1183
  ("POST", "/v1/workbench/run", {
1014
1184
  "language": "python",
1015
1185
  "code": "print('ready')",
File without changes
File without changes