stacklink-sdk 0.3.15__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stacklink_sdk-0.3.15/stacklink_sdk.egg-info → stacklink_sdk-0.4.0}/PKG-INFO +1 -1
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/pyproject.toml +1 -1
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/__init__.py +2 -2
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/bots.py +33 -4
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/client.py +8 -11
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/errors.py +4 -3
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/mcp.py +4 -1
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/workbench.py +159 -42
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0/stacklink_sdk.egg-info}/PKG-INFO +1 -1
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_client.py +110 -2
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/LICENSE +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/README.md +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/setup.cfg +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/api_keys.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/approvals.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/artifacts.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/auth_configs.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/billing.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/claude_agent.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/connected_accounts.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/executions.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/knowledge.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/logs.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/memory.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/openai_agents.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/openai_memory.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/projects.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/providers.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/sessions.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/tools.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/triggers.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/types.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/webhooks.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/x_chat.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/SOURCES.txt +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/requires.txt +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/top_level.txt +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_google_docs_provider.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_google_drive_provider.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_google_sheets_provider.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_openai_memory.py +0 -0
- {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_zoho_books_provider.py +0 -0
|
@@ -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.4.0"
|
|
8
8
|
description = "Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "LicenseRef-Proprietary"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from .bots import verify_bot_api_request, sign_bot_trigger_request, BotRun, BotConnectionAction, BotExecutionWait, BotCapabilityFact, BotRequirement
|
|
3
|
+
from .bots import BotAvatar, verify_bot_api_request, sign_bot_trigger_request, BotRun, BotConnectionAction, BotExecutionWait, BotCapabilityFact, BotRequirement
|
|
4
4
|
from .approvals import ApprovalDecision, ApprovalRecord, ApprovalEvent, ApprovalPermissions, CustomerApprovalGrantInput
|
|
5
5
|
from .webhooks import verify_webhook_signature
|
|
6
6
|
from .client import AsyncStacklink, STACKLINK_PYTHON_SDK_VERSION, Stacklink
|
|
@@ -27,7 +27,7 @@ from .types import (
|
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
__all__ = [
|
|
30
|
-
"BotRun", "BotConnectionAction", "BotExecutionWait", "BotCapabilityFact", "BotRequirement",
|
|
30
|
+
"BotAvatar", "BotRun", "BotConnectionAction", "BotExecutionWait", "BotCapabilityFact", "BotRequirement",
|
|
31
31
|
"ApprovalDecision", "ApprovalRecord", "ApprovalEvent", "ApprovalPermissions",
|
|
32
32
|
"CustomerApprovalGrantInput",
|
|
33
33
|
"Stacklink",
|
|
@@ -15,8 +15,15 @@ from urllib.parse import quote
|
|
|
15
15
|
from uuid import uuid4
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class BotAvatar(TypedDict):
|
|
19
|
+
version: Literal[1]
|
|
20
|
+
style: Literal["clay", "pebble", "shapes", "letters"]
|
|
21
|
+
seed: str
|
|
22
|
+
tone: Literal["auto", "mint", "teal", "sky", "iris", "plum", "rose", "coral", "apricot", "sun", "lime", "slate", "ink"]
|
|
23
|
+
|
|
24
|
+
|
|
18
25
|
class _BotCapabilityAccount(TypedDict, total=False):
|
|
19
|
-
account: Literal["project", "customer"]
|
|
26
|
+
account: Literal["project", "customer", "deployment"]
|
|
20
27
|
|
|
21
28
|
|
|
22
29
|
class BotCapabilityFact(_BotCapabilityAccount):
|
|
@@ -46,6 +53,12 @@ class BotExecutionWait(TypedDict):
|
|
|
46
53
|
kind: Literal["execution"]
|
|
47
54
|
id: str
|
|
48
55
|
|
|
56
|
+
class BotAttachmentPreparationWait(TypedDict):
|
|
57
|
+
kind: Literal["attachment_preparation"]
|
|
58
|
+
id: str
|
|
59
|
+
fileId: str
|
|
60
|
+
|
|
61
|
+
|
|
49
62
|
|
|
50
63
|
class BotRequirement(TypedDict):
|
|
51
64
|
capabilityId: str
|
|
@@ -64,6 +77,22 @@ class BotAttachment(TypedDict, total=False):
|
|
|
64
77
|
facts: dict[str, Any]
|
|
65
78
|
warnings: list[dict[str, str]]
|
|
66
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
|
|
67
96
|
|
|
68
97
|
|
|
69
98
|
class BotFileUpload(TypedDict):
|
|
@@ -107,7 +136,7 @@ class BotRun(TypedDict, total=False):
|
|
|
107
136
|
finalText: str | None
|
|
108
137
|
output: Any
|
|
109
138
|
error: Any
|
|
110
|
-
waitingOperation: BotConnectionAction | BotExecutionWait | None
|
|
139
|
+
waitingOperation: BotConnectionAction | BotExecutionWait | BotAttachmentPreparationWait | None
|
|
111
140
|
connection: BotConnectionAction | None
|
|
112
141
|
capabilities: list[BotCapabilityFact]
|
|
113
142
|
requirements: list[BotRequirement]
|
|
@@ -218,8 +247,8 @@ class BotsResource:
|
|
|
218
247
|
def set_state(self, bot_id: str, state: str, *, pause_for: str = "manual", reason: str = ""):
|
|
219
248
|
return self.client.request("POST", self._path(bot_id) + "/state", json_body={"state": state, "pauseFor": pause_for, "reason": reason})
|
|
220
249
|
|
|
221
|
-
def create(self, name: str):
|
|
222
|
-
return self.client.request("POST", "/v1/bots", json_body={"name": name})
|
|
250
|
+
def create(self, name: str, *, public_description: str = "", icon_asset_id: str | None = None, avatar: BotAvatar | None = None):
|
|
251
|
+
return self.client.request("POST", "/v1/bots", json_body={"name": name, "publicDescription": public_description, "iconAssetId": icon_asset_id, **({"avatar": avatar} if avatar is not None else {})})
|
|
223
252
|
|
|
224
253
|
def set_customer_credential(self, bot_id: str, *, api_id: str, session_id: str, secret: str):
|
|
225
254
|
return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"apiId": api_id, "sessionId": session_id, "secret": secret})
|
|
@@ -37,7 +37,7 @@ from .types import Json, Transport
|
|
|
37
37
|
from .webhooks import WebhooksResource
|
|
38
38
|
from .workbench import WorkbenchResource
|
|
39
39
|
|
|
40
|
-
STACKLINK_PYTHON_SDK_VERSION = "0.
|
|
40
|
+
STACKLINK_PYTHON_SDK_VERSION = "0.4.0"
|
|
41
41
|
STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
|
|
42
42
|
|
|
43
43
|
|
|
@@ -356,18 +356,12 @@ class Stacklink:
|
|
|
356
356
|
"user-agent": STACKLINK_PYTHON_USER_AGENT,
|
|
357
357
|
"x-stacklink-client-channel": "python_sdk",
|
|
358
358
|
"x-stacklink-client-name": "stacklink-python",
|
|
359
|
+
"x-stacklink-workbench-protocol": "2",
|
|
359
360
|
"x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
|
|
360
361
|
}
|
|
361
362
|
|
|
362
363
|
def _handle_response(self, status: int, headers: Mapping[str, str], body: Json) -> Json:
|
|
363
|
-
|
|
364
|
-
return body
|
|
365
|
-
message = body.get("message") if isinstance(body, dict) else None
|
|
366
|
-
code = body.get("code") if isinstance(body, dict) else None
|
|
367
|
-
details = body.get("details") if isinstance(body, dict) else None
|
|
368
|
-
if status == 429:
|
|
369
|
-
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
|
|
370
|
-
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
|
|
364
|
+
return _handle_response(status, headers, body)
|
|
371
365
|
|
|
372
366
|
def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
|
|
373
367
|
event_id = None
|
|
@@ -671,6 +665,7 @@ class AsyncStacklink:
|
|
|
671
665
|
"user-agent": STACKLINK_PYTHON_USER_AGENT,
|
|
672
666
|
"x-stacklink-client-channel": "python_sdk",
|
|
673
667
|
"x-stacklink-client-name": "stacklink-python",
|
|
668
|
+
"x-stacklink-workbench-protocol": "2",
|
|
674
669
|
"x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
|
|
675
670
|
}
|
|
676
671
|
|
|
@@ -874,9 +869,11 @@ def _handle_response(status: int, headers: Mapping[str, str], body: Json) -> Jso
|
|
|
874
869
|
message = body.get("message") if isinstance(body, dict) else None
|
|
875
870
|
code = body.get("code") if isinstance(body, dict) else None
|
|
876
871
|
details = body.get("details") if isinstance(body, dict) else None
|
|
872
|
+
failure_id = body.get("failureId") if isinstance(body, dict) else None
|
|
873
|
+
failure_id = failure_id or headers.get("x-stacklink-failure-id")
|
|
877
874
|
if status == 429:
|
|
878
|
-
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
|
|
879
|
-
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
|
|
875
|
+
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
|
|
876
|
+
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
|
|
880
877
|
|
|
881
878
|
|
|
882
879
|
def _timeout_error(timeout_seconds: float, request_id: str) -> StacklinkError:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
class StacklinkError(Exception):
|
|
4
|
-
def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None):
|
|
4
|
+
def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None, failure_id: str | None = None):
|
|
5
5
|
super().__init__(message)
|
|
6
|
+
self.failure_id = failure_id
|
|
6
7
|
self.status_code = status_code
|
|
7
8
|
self.code = code
|
|
8
9
|
self.details = details
|
|
@@ -10,7 +11,7 @@ class StacklinkError(Exception):
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class RateLimitError(StacklinkError):
|
|
13
|
-
def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None):
|
|
14
|
-
super().__init__(message, status_code=status_code, code=code, details=details)
|
|
14
|
+
def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None, failure_id: str | None = None):
|
|
15
|
+
super().__init__(message, status_code=status_code, code=code, details=details, headers=headers, failure_id=failure_id)
|
|
15
16
|
self.headers = headers or {}
|
|
16
17
|
|
|
@@ -77,6 +77,7 @@ def unwrap_tool_result(response: Any):
|
|
|
77
77
|
status_code=400,
|
|
78
78
|
code=str(error.get("code")) if error.get("code") is not None else None,
|
|
79
79
|
details=error.get("data"),
|
|
80
|
+
failure_id=error["data"].get("failureId") if isinstance(error.get("data"), dict) else None,
|
|
80
81
|
)
|
|
81
82
|
result = response.get("result")
|
|
82
83
|
if not isinstance(result, dict):
|
|
@@ -87,7 +88,9 @@ def unwrap_tool_result(response: Any):
|
|
|
87
88
|
for item in result.get("content", [])
|
|
88
89
|
if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str)
|
|
89
90
|
)
|
|
90
|
-
|
|
91
|
+
structured = result.get("structuredContent")
|
|
92
|
+
failure = structured.get("error") if isinstance(structured, dict) else None
|
|
93
|
+
raise StacklinkError(text or "Stacklink MCP tool call failed.", status_code=400, code="mcp_tool_error", details=result, failure_id=failure.get("failureId") if isinstance(failure, dict) else None)
|
|
91
94
|
if "structuredContent" in result:
|
|
92
95
|
return result["structuredContent"]
|
|
93
96
|
content = result.get("content")
|
|
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import time
|
|
5
5
|
import threading
|
|
6
|
+
from uuid import uuid4
|
|
6
7
|
from email.utils import parsedate_to_datetime
|
|
7
8
|
import httpx
|
|
8
|
-
from typing import Any, Iterator, Literal, Mapping, TypedDict, cast
|
|
9
|
+
from typing import Any, BinaryIO, Iterator, Literal, Mapping, TypedDict, cast
|
|
9
10
|
from urllib.error import HTTPError
|
|
10
11
|
from urllib.request import Request, urlopen
|
|
11
12
|
|
|
@@ -18,6 +19,10 @@ WorkbenchExecutionKind = Literal["workbench_code", "workbench_bash"]
|
|
|
18
19
|
WorkbenchReplayEnvironmentMode = Literal["require_match", "restore_snapshot", "current"]
|
|
19
20
|
WorkbenchExecutionStatus = Literal[
|
|
20
21
|
"waiting_for_approval",
|
|
22
|
+
"checkpointing",
|
|
23
|
+
"waiting_saved",
|
|
24
|
+
"waiting_ready",
|
|
25
|
+
"resuming",
|
|
21
26
|
"queued",
|
|
22
27
|
"started",
|
|
23
28
|
"running",
|
|
@@ -141,7 +146,30 @@ class WorkbenchSessionDeleteReceipt(TypedDict):
|
|
|
141
146
|
databaseDeleted: Literal[True]
|
|
142
147
|
|
|
143
148
|
|
|
149
|
+
class WorkbenchWaitContinuation(TypedDict):
|
|
150
|
+
language: WorkbenchLanguage
|
|
151
|
+
code: str
|
|
152
|
+
timeoutMs: int
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class WorkbenchKeepReadyConsent(TypedDict):
|
|
156
|
+
maximumWaitMs: int
|
|
157
|
+
maximumChargeMicros: str
|
|
158
|
+
priceMicros: str
|
|
159
|
+
unitQuantity: int
|
|
160
|
+
rateVersion: str
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class WorkbenchWaitRequest(TypedDict, total=False):
|
|
164
|
+
mode: Literal["saved", "keep_ready"]
|
|
165
|
+
reason: Literal["approval", "connection", "user_input", "external_event"]
|
|
166
|
+
maximumWaitMs: int
|
|
167
|
+
continuation: WorkbenchWaitContinuation
|
|
168
|
+
keepReady: WorkbenchKeepReadyConsent
|
|
169
|
+
|
|
170
|
+
|
|
144
171
|
class WorkbenchRunInput(TypedDict, total=False):
|
|
172
|
+
afterExecution: WorkbenchWaitRequest
|
|
145
173
|
language: WorkbenchLanguage
|
|
146
174
|
code: str
|
|
147
175
|
workbenchSessionId: str
|
|
@@ -158,6 +186,7 @@ class WorkbenchRunInput(TypedDict, total=False):
|
|
|
158
186
|
|
|
159
187
|
|
|
160
188
|
class WorkbenchBashInput(TypedDict, total=False):
|
|
189
|
+
afterExecution: WorkbenchWaitRequest
|
|
161
190
|
command: str
|
|
162
191
|
workbenchSessionId: str
|
|
163
192
|
timeoutMs: int
|
|
@@ -173,6 +202,8 @@ class WorkbenchBashInput(TypedDict, total=False):
|
|
|
173
202
|
|
|
174
203
|
|
|
175
204
|
class WorkbenchExecutionLog(TypedDict, total=False):
|
|
205
|
+
waitRevision: int
|
|
206
|
+
waitState: dict[str, Any] | None
|
|
176
207
|
id: str
|
|
177
208
|
workbenchSessionId: str
|
|
178
209
|
operation: str
|
|
@@ -289,8 +320,21 @@ class WorkbenchOutputPreview(TypedDict):
|
|
|
289
320
|
preview: str
|
|
290
321
|
|
|
291
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
|
+
|
|
292
336
|
class WorkbenchFile(TypedDict, total=False):
|
|
293
|
-
id: str
|
|
337
|
+
id: str | None
|
|
294
338
|
path: str
|
|
295
339
|
contentType: str
|
|
296
340
|
sizeBytes: int | str
|
|
@@ -310,9 +354,12 @@ class WorkbenchFileResponse(TypedDict):
|
|
|
310
354
|
class WorkbenchFileListResponse(TypedDict, total=False):
|
|
311
355
|
files: list[WorkbenchFile]
|
|
312
356
|
runtimeFiles: list[WorkbenchFile]
|
|
357
|
+
nextCursor: str | None
|
|
358
|
+
authoritative: bool
|
|
313
359
|
|
|
314
360
|
|
|
315
361
|
class WorkbenchFileMutationResponse(TypedDict, total=False):
|
|
362
|
+
operation: WorkbenchStorageOperation
|
|
316
363
|
file: WorkbenchFile
|
|
317
364
|
uploadId: str
|
|
318
365
|
finalized: bool
|
|
@@ -335,13 +382,16 @@ class WorkbenchFileUploadPrepareResponse(TypedDict):
|
|
|
335
382
|
upload: WorkbenchFileSignedUpload
|
|
336
383
|
|
|
337
384
|
|
|
338
|
-
class WorkbenchFileSignedDownload(TypedDict):
|
|
385
|
+
class WorkbenchFileSignedDownload(TypedDict, total=False):
|
|
339
386
|
file: WorkbenchFile
|
|
340
387
|
downloadUrl: str
|
|
341
388
|
expiresAt: str
|
|
389
|
+
requiredHeaders: dict[str, str]
|
|
342
390
|
|
|
343
391
|
|
|
344
392
|
class WorkbenchArtifact(TypedDict, total=False):
|
|
393
|
+
operationId: str | None
|
|
394
|
+
workbenchSessionId: str
|
|
345
395
|
id: str
|
|
346
396
|
artifactRef: str
|
|
347
397
|
path: str
|
|
@@ -487,7 +537,7 @@ class WorkbenchExecutionStateResponse(TypedDict, total=False):
|
|
|
487
537
|
class WorkbenchExecutionResponse(TypedDict, total=False):
|
|
488
538
|
approvalRequestId: str
|
|
489
539
|
executed: bool
|
|
490
|
-
nextAction: Literal["wait_for_approval"]
|
|
540
|
+
nextAction: Literal["wait_for_approval", "resume_saved_work"]
|
|
491
541
|
workbenchSession: WorkbenchSession
|
|
492
542
|
executionKind: WorkbenchExecutionKind
|
|
493
543
|
executionId: str
|
|
@@ -605,7 +655,7 @@ def _wait_for_workbench_response(
|
|
|
605
655
|
execution_id = accepted.get("executionId")
|
|
606
656
|
execution_kind = accepted.get("executionKind")
|
|
607
657
|
if (
|
|
608
|
-
accepted.get("status")
|
|
658
|
+
accepted.get("status") in ("waiting_for_approval", "waiting_saved", "waiting_ready")
|
|
609
659
|
or not isinstance(session_id, str)
|
|
610
660
|
or not isinstance(execution_id, str)
|
|
611
661
|
or execution_kind not in {"workbench_code", "workbench_bash"}
|
|
@@ -620,13 +670,15 @@ def _wait_for_workbench_response(
|
|
|
620
670
|
{"waitTimeoutMs": 10_000},
|
|
621
671
|
))
|
|
622
672
|
execution = waited.get("execution")
|
|
623
|
-
if not isinstance(execution, dict) or execution.get("status") not in {
|
|
673
|
+
if not isinstance(execution, dict) or (execution.get("nextAction") != "wait_for_approval" and execution.get("status") not in {
|
|
624
674
|
"waiting_for_approval",
|
|
675
|
+
"waiting_saved",
|
|
676
|
+
"waiting_ready",
|
|
625
677
|
"completed",
|
|
626
678
|
"failed",
|
|
627
679
|
"cancelled",
|
|
628
680
|
"timed_out",
|
|
629
|
-
}:
|
|
681
|
+
}):
|
|
630
682
|
continue
|
|
631
683
|
response: WorkbenchExecutionResponse = {
|
|
632
684
|
**accepted,
|
|
@@ -638,9 +690,9 @@ def _wait_for_workbench_response(
|
|
|
638
690
|
}
|
|
639
691
|
if isinstance(execution.get("approvalRequestId"), str):
|
|
640
692
|
response["approvalRequestId"] = execution["approvalRequestId"]
|
|
641
|
-
if execution.get("status")
|
|
693
|
+
if execution.get("status") in ("waiting_for_approval", "waiting_saved", "waiting_ready") or execution.get("nextAction") == "wait_for_approval":
|
|
642
694
|
response["executed"] = False
|
|
643
|
-
response["nextAction"] = "wait_for_approval"
|
|
695
|
+
response["nextAction"] = "wait_for_approval" if execution.get("status") == "waiting_for_approval" or execution.get("nextAction") == "wait_for_approval" else "resume_saved_work"
|
|
644
696
|
output_mode = execution.get("outputMode")
|
|
645
697
|
if output_mode in {"inline", "preview", "events", "artifact"}:
|
|
646
698
|
response["outputMode"] = cast(WorkbenchOutputMode, output_mode)
|
|
@@ -659,11 +711,11 @@ class WorkbenchFiles:
|
|
|
659
711
|
def __init__(self, sandbox: "SessionSandbox"):
|
|
660
712
|
self.sandbox = sandbox
|
|
661
713
|
|
|
662
|
-
def list(self) -> WorkbenchFileListResponse:
|
|
714
|
+
def list(self, *, cursor: str | None = None, limit: int = 100) -> WorkbenchFileListResponse:
|
|
663
715
|
return cast(WorkbenchFileListResponse, self.sandbox.client.request(
|
|
664
716
|
"GET",
|
|
665
717
|
f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
|
|
666
|
-
query={"sessionId": self.sandbox.platform_session_id},
|
|
718
|
+
query={"sessionId": self.sandbox.platform_session_id, "cursor": cursor, "limit": limit},
|
|
667
719
|
))
|
|
668
720
|
|
|
669
721
|
def read(self, path: str) -> WorkbenchFileResponse:
|
|
@@ -680,33 +732,42 @@ class WorkbenchFiles:
|
|
|
680
732
|
*,
|
|
681
733
|
content_type: str | None = None,
|
|
682
734
|
metadata: Mapping[str, Any] | None = None,
|
|
735
|
+
upload_id: str | None = None,
|
|
683
736
|
) -> WorkbenchFileResponse:
|
|
684
737
|
raw = content.encode("utf-8") if isinstance(content, str) else content
|
|
685
738
|
body = {
|
|
686
739
|
"sessionId": self.sandbox.platform_session_id,
|
|
687
740
|
"path": path,
|
|
741
|
+
"uploadId": upload_id or str(uuid4()),
|
|
688
742
|
"contentBase64": base64.b64encode(raw).decode("ascii"),
|
|
689
743
|
"metadata": dict(metadata or {}),
|
|
690
744
|
}
|
|
691
745
|
if content_type is not None:
|
|
692
746
|
body["contentType"] = content_type
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
f"/v1/workbench/sessions/{
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
747
|
+
workspace_id = self.sandbox.ensure_session_id()
|
|
748
|
+
try:
|
|
749
|
+
result = self.sandbox.client.request("PUT", f"/v1/workbench/sessions/{workspace_id}/files", json_body=body)
|
|
750
|
+
return cast(WorkbenchFileResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, workspace_id, result))
|
|
751
|
+
except StacklinkError as error:
|
|
752
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
753
|
+
raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
|
|
754
|
+
details={**details, "uploadId": body["uploadId"], "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
|
|
755
|
+
headers=error.headers, failure_id=error.failure_id) from error
|
|
756
|
+
|
|
757
|
+
def prepare_upload(self, path: str, *, content_type: str, size_bytes: int, upload_id: str | None = None) -> WorkbenchFileUploadPrepareResponse:
|
|
758
|
+
upload_id = upload_id or str(uuid4())
|
|
759
|
+
workspace_id = self.sandbox.ensure_session_id()
|
|
760
|
+
try:
|
|
761
|
+
return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
|
|
762
|
+
"POST", f"/v1/workbench/sessions/{workspace_id}/files/upload-url",
|
|
763
|
+
json_body={"sessionId": self.sandbox.platform_session_id, "path": path,
|
|
764
|
+
"contentType": content_type, "sizeBytes": size_bytes, "uploadId": upload_id},
|
|
765
|
+
))
|
|
766
|
+
except StacklinkError as error:
|
|
767
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
768
|
+
raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
|
|
769
|
+
details={**details, "uploadId": upload_id, "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
|
|
770
|
+
headers=error.headers, failure_id=error.failure_id) from error
|
|
710
771
|
|
|
711
772
|
def finalize_upload(
|
|
712
773
|
self,
|
|
@@ -714,7 +775,7 @@ class WorkbenchFiles:
|
|
|
714
775
|
*,
|
|
715
776
|
metadata: Mapping[str, Any] | None = None,
|
|
716
777
|
) -> WorkbenchFileMutationResponse:
|
|
717
|
-
|
|
778
|
+
result = self.sandbox.client.request(
|
|
718
779
|
"POST",
|
|
719
780
|
f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/finalize-upload",
|
|
720
781
|
json_body={
|
|
@@ -722,21 +783,31 @@ class WorkbenchFiles:
|
|
|
722
783
|
"uploadId": upload_id,
|
|
723
784
|
"metadata": dict(metadata or {}),
|
|
724
785
|
},
|
|
725
|
-
)
|
|
786
|
+
)
|
|
787
|
+
return cast(WorkbenchFileMutationResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
|
|
726
788
|
|
|
727
789
|
def upload_large(
|
|
728
790
|
self,
|
|
729
791
|
path: str,
|
|
730
|
-
content: str | bytes,
|
|
792
|
+
content: str | bytes | BinaryIO,
|
|
731
793
|
*,
|
|
732
794
|
content_type: str | None = None,
|
|
733
795
|
metadata: Mapping[str, Any] | None = None,
|
|
796
|
+
transfer_timeout_seconds: float = 300.0,
|
|
797
|
+
upload_id: str | None = None,
|
|
734
798
|
) -> WorkbenchFileMutationResponse:
|
|
735
799
|
raw = content.encode("utf-8") if isinstance(content, str) else content
|
|
736
800
|
resolved_content_type = content_type or (
|
|
737
801
|
"text/plain; charset=utf-8" if isinstance(content, str) else "application/octet-stream"
|
|
738
802
|
)
|
|
739
|
-
|
|
803
|
+
if isinstance(raw, bytes):
|
|
804
|
+
size_bytes = len(raw)
|
|
805
|
+
else:
|
|
806
|
+
position = raw.tell()
|
|
807
|
+
raw.seek(0, 2)
|
|
808
|
+
size_bytes = raw.tell() - position
|
|
809
|
+
raw.seek(position)
|
|
810
|
+
prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=size_bytes, upload_id=upload_id)
|
|
740
811
|
upload = prepared.get("upload") if isinstance(prepared, dict) else None
|
|
741
812
|
if (
|
|
742
813
|
not isinstance(upload, dict)
|
|
@@ -745,13 +816,16 @@ class WorkbenchFiles:
|
|
|
745
816
|
):
|
|
746
817
|
raise StacklinkError("Stacklink did not return a signed Workbench upload URL.")
|
|
747
818
|
headers = upload.get("requiredHeaders") if isinstance(upload.get("requiredHeaders"), dict) else {}
|
|
748
|
-
request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={str(key): str(value) for key, value in headers.items()})
|
|
819
|
+
request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={**{str(key): str(value) for key, value in headers.items()}, "Content-Length": str(size_bytes)})
|
|
749
820
|
try:
|
|
750
|
-
with urlopen(request, timeout=
|
|
821
|
+
with urlopen(request, timeout=transfer_timeout_seconds) as response:
|
|
751
822
|
if response.status >= 400:
|
|
752
823
|
raise StacklinkError(f"Workbench file upload failed with {response.status}.")
|
|
753
824
|
except HTTPError as error:
|
|
754
|
-
|
|
825
|
+
if error.code != 412:
|
|
826
|
+
raise StacklinkError(f"Workbench file upload failed with {error.code}.", status_code=error.code, code="workbench_file_direct_upload_failed", details={"uploadId": upload["uploadId"], "workbenchSessionId": self.sandbox.ensure_session_id(), "sessionId": self.sandbox.platform_session_id}) from error
|
|
827
|
+
except (TimeoutError, OSError) as error:
|
|
828
|
+
raise StacklinkError("Upload outcome is unknown. Finalize the same uploadId to reconcile.", code="workbench_upload_outcome_unknown", details={"uploadId": upload["uploadId"]}) from error
|
|
755
829
|
return self.finalize_upload(
|
|
756
830
|
upload["uploadId"],
|
|
757
831
|
metadata=metadata,
|
|
@@ -767,7 +841,7 @@ class WorkbenchFiles:
|
|
|
767
841
|
if not isinstance(download_url, str):
|
|
768
842
|
raise StacklinkError("Stacklink did not return a signed Workbench download URL.")
|
|
769
843
|
try:
|
|
770
|
-
with urlopen(Request(download_url, method="GET"), timeout=
|
|
844
|
+
with urlopen(Request(download_url, method="GET", headers=signed.get("requiredHeaders", {})), timeout=300.0) as response:
|
|
771
845
|
return response.read()
|
|
772
846
|
except HTTPError as error:
|
|
773
847
|
raise StacklinkError(f"Workbench file download failed with {error.code}.") from error
|
|
@@ -978,18 +1052,27 @@ class WorkbenchArtifacts:
|
|
|
978
1052
|
label: str | None = None,
|
|
979
1053
|
content_type: str | None = None,
|
|
980
1054
|
metadata: Mapping[str, Any] | None = None,
|
|
1055
|
+
artifact_ref: str | None = None,
|
|
981
1056
|
) -> WorkbenchArtifactResponse:
|
|
982
1057
|
body: dict[str, Any] = {
|
|
983
1058
|
"sessionId": self.sandbox.platform_session_id,
|
|
984
1059
|
"workbenchSessionId": self.sandbox.ensure_session_id(),
|
|
985
1060
|
"path": path,
|
|
1061
|
+
"artifactRef": artifact_ref or f"wba_{uuid4()}",
|
|
986
1062
|
"metadata": dict(metadata or {}),
|
|
987
1063
|
}
|
|
988
1064
|
if label is not None:
|
|
989
1065
|
body["label"] = label
|
|
990
1066
|
if content_type is not None:
|
|
991
1067
|
body["contentType"] = content_type
|
|
992
|
-
|
|
1068
|
+
try:
|
|
1069
|
+
result = self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body)
|
|
1070
|
+
return cast(WorkbenchArtifactResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
|
|
1071
|
+
except StacklinkError as error:
|
|
1072
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
1073
|
+
raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
|
|
1074
|
+
details={**details, "artifactRef": body["artifactRef"], "workbenchSessionId": body["workbenchSessionId"], "sessionId": body["sessionId"]},
|
|
1075
|
+
headers=error.headers, failure_id=error.failure_id) from error
|
|
993
1076
|
|
|
994
1077
|
def download_url(self, artifact_id: str) -> WorkbenchArtifactDownloadResponse:
|
|
995
1078
|
return cast(WorkbenchArtifactDownloadResponse, self.sandbox.client.workbench.artifact_download_url(artifact_id))
|
|
@@ -1018,6 +1101,12 @@ class SessionSandbox:
|
|
|
1018
1101
|
self.runs = WorkbenchRuns(self)
|
|
1019
1102
|
self.artifacts = WorkbenchArtifacts(self)
|
|
1020
1103
|
|
|
1104
|
+
def resume(self, execution_id: str, expected_wait_revision: int, checkpoint_id: str) -> WorkbenchExecutionResponse:
|
|
1105
|
+
return self.client.request("POST", "/v1/workbench/resume", json_body={
|
|
1106
|
+
"sessionId": self.platform_session_id, "executionId": execution_id,
|
|
1107
|
+
"expectedWaitRevision": expected_wait_revision, "checkpointId": checkpoint_id,
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1021
1110
|
def use(
|
|
1022
1111
|
self,
|
|
1023
1112
|
workbench_session_id: str | None = None,
|
|
@@ -1096,6 +1185,10 @@ class WorkbenchResource:
|
|
|
1096
1185
|
|
|
1097
1186
|
def run(self, body: dict[str, Any]):
|
|
1098
1187
|
response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body))
|
|
1188
|
+
if isinstance(response.get("operation"), dict):
|
|
1189
|
+
operation = response["operation"]
|
|
1190
|
+
completed = self.wait_operation(body["sessionId"], operation["workbenchSessionId"], operation["operationId"])
|
|
1191
|
+
return {**{key: value for key, value in response.items() if key != "operation"}, **completed}
|
|
1099
1192
|
if body.get("operation") == "run_code" and body.get("background") is not True:
|
|
1100
1193
|
return _wait_for_workbench_response(self.client, body, response)
|
|
1101
1194
|
return response
|
|
@@ -1106,6 +1199,12 @@ class WorkbenchResource:
|
|
|
1106
1199
|
return _wait_for_workbench_response(self.client, body, response)
|
|
1107
1200
|
return response
|
|
1108
1201
|
|
|
1202
|
+
def resume(self, session_id: str, execution_id: str, *, expected_wait_revision: int, checkpoint_id: str):
|
|
1203
|
+
return self.client.request("POST", "/v1/workbench/resume", json_body={
|
|
1204
|
+
"sessionId": session_id, "executionId": execution_id,
|
|
1205
|
+
"expectedWaitRevision": expected_wait_revision, "checkpointId": checkpoint_id,
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1109
1208
|
def compute_status(self, session_id: str, workbench_session_id: str) -> WorkbenchComputeStatus:
|
|
1110
1209
|
return cast(WorkbenchComputeStatus, self.client.request("POST", "/v1/workbench/run", json_body={
|
|
1111
1210
|
"sessionId": session_id, "workbenchSessionId": workbench_session_id, "operation": "compute_status",
|
|
@@ -1118,12 +1217,30 @@ class WorkbenchResource:
|
|
|
1118
1217
|
"operation": "destroy",
|
|
1119
1218
|
})
|
|
1120
1219
|
|
|
1220
|
+
def operation(self, session_id: str, workbench_session_id: str, operation_id: str):
|
|
1221
|
+
return self.client.request("GET", f"/v1/workbench/sessions/{workbench_session_id}/operations/{operation_id}", query={"sessionId": session_id})
|
|
1222
|
+
|
|
1223
|
+
def wait_operation(self, session_id: str, workbench_session_id: str, operation_id: str, *, timeout_seconds: float = 300.0):
|
|
1224
|
+
deadline = time.monotonic() + timeout_seconds
|
|
1225
|
+
while time.monotonic() < deadline:
|
|
1226
|
+
try:
|
|
1227
|
+
value = self.operation(session_id, workbench_session_id, operation_id)["operation"]
|
|
1228
|
+
except StacklinkError as error:
|
|
1229
|
+
raise StacklinkError("Operation observation stopped. Resume with the same operation ID.", status_code=error.status_code, code="workbench_operation_observation_failed", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id, "causeCode": error.code}) from error
|
|
1230
|
+
if value["status"] == "completed":
|
|
1231
|
+
return value["result"]
|
|
1232
|
+
if value["status"] == "failed":
|
|
1233
|
+
raise StacklinkError("Workbench operation needs attention.", code="workbench_operation_failed", details=value)
|
|
1234
|
+
time.sleep(min(max(0.1, value.get("retryAfterMs", 1000) / 1000), max(0, deadline - time.monotonic())))
|
|
1235
|
+
raise StacklinkError("Workbench operation is still running. Resume with its operation ID.", code="workbench_operation_pending", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id})
|
|
1236
|
+
|
|
1237
|
+
def finish_operation(self, session_id: str, workbench_session_id: str, result):
|
|
1238
|
+
operation = result.get("operation") if isinstance(result, dict) else None
|
|
1239
|
+
return self.wait_operation(session_id, workbench_session_id, operation["operationId"]) if isinstance(operation, dict) else result
|
|
1240
|
+
|
|
1121
1241
|
def delete_session(self, session_id: str, workbench_session_id: str) -> WorkbenchSessionDeleteReceipt:
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
f"/v1/workbench/sessions/{workbench_session_id}",
|
|
1125
|
-
query={"sessionId": session_id},
|
|
1126
|
-
))
|
|
1242
|
+
result = self.client.request("DELETE", f"/v1/workbench/sessions/{workbench_session_id}", query={"sessionId": session_id})
|
|
1243
|
+
return cast(WorkbenchSessionDeleteReceipt, self.finish_operation(session_id, workbench_session_id, result))
|
|
1127
1244
|
|
|
1128
1245
|
def sessions(self, session_id: str):
|
|
1129
1246
|
return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
|
|
@@ -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,70 @@ def stalled_stream_server(*, final_result=False, execution_page=False):
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
class StacklinkClientTests(unittest.TestCase):
|
|
66
|
+
def test_workbench_lost_acknowledgement_keeps_upload_and_artifact_identity(self):
|
|
67
|
+
bodies = []
|
|
68
|
+
def transport(method, path, headers, body, query, timeout):
|
|
69
|
+
bodies.append(body)
|
|
70
|
+
return 504, {}, {"code": "outbound_http_timeout", "message": "Lost acknowledgement"}
|
|
71
|
+
client = Stacklink(api_key="test", transport=transport, max_attempts=1)
|
|
72
|
+
sandbox = SessionSandbox(client, "session-1")
|
|
73
|
+
sandbox.workbench_session_id = "workspace-1"
|
|
74
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
75
|
+
sandbox.files.upload("नतीजा.bin", b"result")
|
|
76
|
+
upload_id = caught.exception.details["uploadId"]
|
|
77
|
+
with self.assertRaises(StacklinkError):
|
|
78
|
+
sandbox.files.upload("नतीजा.bin", b"result", upload_id=upload_id)
|
|
79
|
+
self.assertEqual(bodies[0]["uploadId"], bodies[1]["uploadId"])
|
|
80
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
81
|
+
sandbox.artifacts.promote("नतीजा.bin")
|
|
82
|
+
artifact_ref = caught.exception.details["artifactRef"]
|
|
83
|
+
with self.assertRaises(StacklinkError):
|
|
84
|
+
sandbox.artifacts.promote("नतीजा.bin", artifact_ref=artifact_ref)
|
|
85
|
+
self.assertEqual(bodies[2]["artifactRef"], bodies[3]["artifactRef"])
|
|
86
|
+
|
|
87
|
+
def test_workbench_storage_wait_retains_identity_and_never_repeats_deletion(self):
|
|
88
|
+
calls = []
|
|
89
|
+
receipt = {"deleted": True, "databaseDeleted": True, "workbenchSessionId": "workspace-1"}
|
|
90
|
+
def transport(method, path, headers, body, query, timeout):
|
|
91
|
+
calls.append((method, path))
|
|
92
|
+
if method == "DELETE":
|
|
93
|
+
return 202, {}, {"operation": {"operationId": "operation-1"}}
|
|
94
|
+
return 200, {}, {"operation": {"operationId": "operation-1", "status": "completed", "result": receipt}}
|
|
95
|
+
client = Stacklink(api_key="test", transport=transport)
|
|
96
|
+
self.assertEqual(client.workbench.delete_session("session-1", "workspace-1"), receipt)
|
|
97
|
+
self.assertEqual([method for method, _ in calls], ["DELETE", "GET"])
|
|
98
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
99
|
+
client.workbench.wait_operation("session-1", "workspace-1", "operation-1", timeout_seconds=0)
|
|
100
|
+
self.assertEqual(caught.exception.details, {"sessionId": "session-1", "workbenchSessionId": "workspace-1", "operationId": "operation-1"})
|
|
101
|
+
self.assertEqual(len(calls), 2)
|
|
102
|
+
|
|
103
|
+
def test_workbench_storage_observation_failure_keeps_resume_handle(self):
|
|
104
|
+
def transport(method, path, headers, body, query, timeout):
|
|
105
|
+
return 403, {}, {"code": "access_denied", "message": "denied"}
|
|
106
|
+
client = Stacklink(api_key="test", transport=transport)
|
|
107
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
108
|
+
client.workbench.wait_operation("session-1", "workspace-1", "operation-1")
|
|
109
|
+
self.assertEqual(caught.exception.code, "workbench_operation_observation_failed")
|
|
110
|
+
self.assertEqual(caught.exception.details["operationId"], "operation-1")
|
|
111
|
+
|
|
112
|
+
def test_bot_avatar_recipe_is_preserved_for_sync_and_async_create(self):
|
|
113
|
+
recipe = {"version": 1, "style": "clay", "seed": "Sales assistant", "tone": "mint"}
|
|
114
|
+
payloads = []
|
|
115
|
+
def respond(request):
|
|
116
|
+
payload = json.loads(request.content)
|
|
117
|
+
payloads.append(payload)
|
|
118
|
+
return httpx.Response(200, json={"id": "bot", "identity": {"avatar": payload["avatar"], "pictureUrl": "https://example.test/avatar.png"}})
|
|
119
|
+
with httpx.Client(transport=httpx.MockTransport(respond)) as http:
|
|
120
|
+
client = Stacklink(api_key="test", base_url="https://example.test", http_client=http)
|
|
121
|
+
self.assertEqual(client.bots.create("Sales", avatar=recipe)["identity"]["avatar"], recipe)
|
|
122
|
+
async def check_async():
|
|
123
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http:
|
|
124
|
+
client = AsyncStacklink(api_key="test", base_url="https://example.test", http_client=http)
|
|
125
|
+
result = await client.bots.create("Sales", avatar=recipe)
|
|
126
|
+
self.assertEqual(result["identity"]["avatar"], recipe)
|
|
127
|
+
asyncio.run(check_async())
|
|
128
|
+
self.assertEqual([item["avatar"] for item in payloads], [recipe, recipe])
|
|
129
|
+
|
|
66
130
|
def test_customer_grant_management_uses_session_identity(self):
|
|
67
131
|
calls = []
|
|
68
132
|
def transport(method, path, headers, body, query, timeout):
|
|
@@ -99,6 +163,30 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
99
163
|
self.assertFalse(result["executed"])
|
|
100
164
|
self.assertEqual(len(calls), 2 if admission_status == "queued" else 1)
|
|
101
165
|
|
|
166
|
+
def test_saved_wait_returns_to_caller_and_preserves_resume_identity(self):
|
|
167
|
+
for status in ("waiting_saved", "waiting_ready", "resuming"):
|
|
168
|
+
calls = []
|
|
169
|
+
state = {"id": "execution-1", "executionKind": "workbench_code", "status": status,
|
|
170
|
+
"waitRevision": 2, "waitState": {"checkpointId": "checkpoint-1"}}
|
|
171
|
+
if status == "resuming":
|
|
172
|
+
state.update({"nextAction": "wait_for_approval", "approvalRequestId": "approval-2"})
|
|
173
|
+
def transport(method, path, headers, body, query, timeout):
|
|
174
|
+
calls.append((path, body))
|
|
175
|
+
if path == "/v1/workbench/run":
|
|
176
|
+
return 202, {}, {"executionId": state["id"], "executionKind": "workbench_code", "status": "queued"}
|
|
177
|
+
if path.endswith("/wait") and len(calls) == 2:
|
|
178
|
+
return 200, {}, {"execution": state}
|
|
179
|
+
if path == "/v1/workbench/resume":
|
|
180
|
+
return 202, {}, {**state, "status": "resuming"}
|
|
181
|
+
raise AssertionError(f"Unexpected saved wait polling: {path}")
|
|
182
|
+
client = Stacklink(api_key="ppk_test", transport=transport)
|
|
183
|
+
result = client.workbench.run({"sessionId": "session-1", "operation": "run_code", "code": "print(1)"})
|
|
184
|
+
self.assertEqual(result["status"], status)
|
|
185
|
+
self.assertEqual(result["nextAction"], "wait_for_approval" if status == "resuming" else "resume_saved_work")
|
|
186
|
+
self.assertEqual(len(calls), 2)
|
|
187
|
+
client.workbench.resume("session-1", "execution-1", expected_wait_revision=2, checkpoint_id="checkpoint-1")
|
|
188
|
+
self.assertEqual(calls[-1], ("/v1/workbench/resume", {"sessionId": "session-1", "executionId": "execution-1", "expectedWaitRevision": 2, "checkpointId": "checkpoint-1"}))
|
|
189
|
+
|
|
102
190
|
def test_approvals_observe_reconnect_and_customer_identity(self):
|
|
103
191
|
calls = []
|
|
104
192
|
def transport(method, path, headers, body, query, timeout):
|
|
@@ -762,6 +850,23 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
762
850
|
lines.extend(decoder.finish())
|
|
763
851
|
self.assertEqual(lines, ['data: {"value":"नमस्ते"}', ''])
|
|
764
852
|
|
|
853
|
+
def test_failure_reference_survives_http_and_mcp_errors(self):
|
|
854
|
+
from stacklink.mcp import unwrap_tool_result
|
|
855
|
+
for status, body in [(502, {"failureId": "failure-body"}), (429, {})]:
|
|
856
|
+
def transport(method, path, headers, request_body, query, timeout):
|
|
857
|
+
return status, {"x-stacklink-failure-id": "failure-header"}, body
|
|
858
|
+
client = Stacklink(api_key="ppk_test", transport=transport)
|
|
859
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
860
|
+
client.tools.search({"sessionId": "session", "query": "gmail"})
|
|
861
|
+
self.assertEqual(caught.exception.failure_id, body.get("failureId", "failure-header"))
|
|
862
|
+
for response in [
|
|
863
|
+
{"error": {"code": -32603, "data": {"failureId": "failure-mcp"}}},
|
|
864
|
+
{"result": {"isError": True, "structuredContent": {"error": {"failureId": "failure-mcp"}}}},
|
|
865
|
+
]:
|
|
866
|
+
with self.assertRaises(StacklinkError) as caught:
|
|
867
|
+
unwrap_tool_result(response)
|
|
868
|
+
self.assertEqual(caught.exception.failure_id, "failure-mcp")
|
|
869
|
+
|
|
765
870
|
def test_rate_limit_error_is_typed(self):
|
|
766
871
|
def transport(method, path, headers, body, query, timeout):
|
|
767
872
|
return 429, {"Retry-After": "60"}, {"code": "rate_limited", "message": "Too many requests."}
|
|
@@ -952,6 +1057,9 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
952
1057
|
self.assertEqual(run.response["result"]["stdout"], "ready")
|
|
953
1058
|
self.assertEqual(run.response["execution"], run.response["executionLog"])
|
|
954
1059
|
self.assertEqual(run.response["result"], run.response["execution"]["result"])
|
|
1060
|
+
upload_id = calls[2][2].pop("uploadId")
|
|
1061
|
+
self.assertIsInstance(upload_id, str)
|
|
1062
|
+
self.assertEqual(len(upload_id), 36)
|
|
955
1063
|
self.assertEqual(calls, [
|
|
956
1064
|
("GET", "/v1/sessions/session-1/bootstrap", None, {}),
|
|
957
1065
|
("POST", "/v1/workbench/sessions", {
|
|
@@ -967,7 +1075,7 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
967
1075
|
"metadata": {},
|
|
968
1076
|
"contentType": "text/plain",
|
|
969
1077
|
}, {}),
|
|
970
|
-
("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1"}),
|
|
1078
|
+
("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1", "limit": 100}),
|
|
971
1079
|
("POST", "/v1/workbench/run", {
|
|
972
1080
|
"language": "python",
|
|
973
1081
|
"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
|
|
File without changes
|
|
File without changes
|