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.
- {stacklink_sdk-0.3.19/stacklink_sdk.egg-info → stacklink_sdk-0.6.2}/PKG-INFO +28 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/README.md +27 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/pyproject.toml +1 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/bots.py +31 -5
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/client.py +11 -11
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/errors.py +4 -3
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/mcp.py +4 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/sessions.py +6 -2
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/workbench.py +124 -44
- stacklink_sdk-0.6.2/stacklink/workflows.py +129 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2/stacklink_sdk.egg-info}/PKG-INFO +28 -1
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/SOURCES.txt +2 -0
- stacklink_sdk-0.6.2/tests/test_amazon_seller_provider.py +38 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_client.py +172 -2
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/LICENSE +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/setup.cfg +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/__init__.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/api_keys.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/approvals.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/artifacts.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/auth_configs.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/billing.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/claude_agent.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/connected_accounts.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/executions.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/knowledge.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/logs.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/memory.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/openai_agents.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/openai_memory.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/projects.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/providers.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/tools.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/triggers.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/types.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/webhooks.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink/x_chat.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/requires.txt +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/top_level.txt +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_google_docs_provider.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_google_drive_provider.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_google_sheets_provider.py +0 -0
- {stacklink_sdk-0.3.19 → stacklink_sdk-0.6.2}/tests/test_openai_memory.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
f"/v1/workbench/sessions/{
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
+
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|