stacklink-sdk 0.4.0__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.4.0/stacklink_sdk.egg-info → stacklink_sdk-0.6.2}/PKG-INFO +28 -1
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/README.md +27 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/pyproject.toml +1 -1
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/bots.py +8 -4
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/client.py +4 -1
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/sessions.py +6 -2
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/workbench.py +15 -8
- stacklink_sdk-0.6.2/stacklink/workflows.py +129 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2/stacklink_sdk.egg-info}/PKG-INFO +28 -1
- {stacklink_sdk-0.4.0 → 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.4.0 → stacklink_sdk-0.6.2}/tests/test_client.py +104 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/LICENSE +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/setup.cfg +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/__init__.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/api_keys.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/approvals.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/artifacts.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/auth_configs.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/billing.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/claude_agent.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/connected_accounts.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/errors.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/executions.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/knowledge.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/logs.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/mcp.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/memory.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/openai_agents.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/openai_memory.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/projects.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/providers.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/tools.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/triggers.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/types.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/webhooks.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink/x_chat.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/requires.txt +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/stacklink_sdk.egg-info/top_level.txt +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_google_docs_provider.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_google_drive_provider.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_google_sheets_provider.py +0 -0
- {stacklink_sdk-0.4.0 → stacklink_sdk-0.6.2}/tests/test_openai_memory.py +0 -0
- {stacklink_sdk-0.4.0 → 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"
|
|
@@ -238,8 +238,8 @@ class BotsResource:
|
|
|
238
238
|
def retry_delivery(self, bot_id: str, run_id: str, delivery_id: str):
|
|
239
239
|
return self.client.request("POST", self._path(bot_id, run_id) + "/deliveries/retry", json_body={"deliveryId": delivery_id})
|
|
240
240
|
|
|
241
|
-
def list(self):
|
|
242
|
-
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 {})})
|
|
243
243
|
|
|
244
244
|
def delete(self, bot_id: str):
|
|
245
245
|
return self.client.request("DELETE", self._path(bot_id))
|
|
@@ -275,6 +275,10 @@ class BotsResource:
|
|
|
275
275
|
def get_run(self, bot_id: str, run_id: str):
|
|
276
276
|
return self.client.request("GET", self._path(bot_id, run_id))
|
|
277
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
|
+
|
|
278
282
|
def wait(self, bot_id: str, run_id: str, *, total_timeout_ms: int = 300_000):
|
|
279
283
|
"""Return the finished run, or a run waiting for a person's approval or connection."""
|
|
280
284
|
if total_timeout_ms <= 0:
|
|
@@ -285,7 +289,7 @@ class BotsResource:
|
|
|
285
289
|
deadline = time.monotonic() + total_timeout_ms / 1000
|
|
286
290
|
while time.monotonic() < deadline:
|
|
287
291
|
run = self.get_run(bot_id, run_id)
|
|
288
|
-
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":
|
|
289
293
|
return run
|
|
290
294
|
time.sleep(min(1, max(0, deadline - time.monotonic())))
|
|
291
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})
|
|
@@ -294,7 +298,7 @@ class BotsResource:
|
|
|
294
298
|
deadline = time.monotonic() + total_timeout_ms / 1000
|
|
295
299
|
while time.monotonic() < deadline:
|
|
296
300
|
run = await self.get_run(bot_id, run_id)
|
|
297
|
-
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":
|
|
298
302
|
return run
|
|
299
303
|
await asyncio.sleep(min(1, max(0, deadline - time.monotonic())))
|
|
300
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")
|
|
@@ -419,6 +421,7 @@ class AsyncStacklink:
|
|
|
419
421
|
max_attempts: int = 3,
|
|
420
422
|
http_client: httpx.AsyncClient | None = None,
|
|
421
423
|
):
|
|
424
|
+
self.workflows = WorkflowsResource(self)
|
|
422
425
|
self.bots = BotsResource(self, async_mode=True)
|
|
423
426
|
self.approvals = ApprovalsResource(self, async_mode=True)
|
|
424
427
|
self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
|
|
@@ -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
|
)
|
|
@@ -549,10 +549,15 @@ class WorkbenchExecutionResponse(TypedDict, total=False):
|
|
|
549
549
|
artifact: WorkbenchArtifact
|
|
550
550
|
|
|
551
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
|
+
|
|
552
557
|
class WorkbenchRun:
|
|
553
558
|
def __init__(self, client, platform_session_id: str, response: WorkbenchExecutionResponse):
|
|
554
559
|
self.client = client
|
|
555
|
-
self.platform_session_id = platform_session_id
|
|
560
|
+
self.platform_session_id = _execution_session_id(response, platform_session_id)
|
|
556
561
|
self.response = response
|
|
557
562
|
self.execution_id = str(response["executionId"])
|
|
558
563
|
self.execution_kind = str(response["executionKind"])
|
|
@@ -651,7 +656,7 @@ def _wait_for_workbench_response(
|
|
|
651
656
|
request: Mapping[str, Any],
|
|
652
657
|
accepted: WorkbenchExecutionResponse,
|
|
653
658
|
) -> WorkbenchExecutionResponse:
|
|
654
|
-
session_id = request.get("sessionId")
|
|
659
|
+
session_id = _execution_session_id(accepted, request.get("sessionId"))
|
|
655
660
|
execution_id = accepted.get("executionId")
|
|
656
661
|
execution_kind = accepted.get("executionKind")
|
|
657
662
|
if (
|
|
@@ -1140,10 +1145,11 @@ class SessionSandbox:
|
|
|
1140
1145
|
"workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
|
|
1141
1146
|
"operation": "run_code",
|
|
1142
1147
|
}
|
|
1143
|
-
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"))
|
|
1144
1149
|
if body.get("background") is not True:
|
|
1145
1150
|
response = _wait_for_workbench_response(self.client, request, response)
|
|
1146
|
-
self.
|
|
1151
|
+
if _execution_session_id(response, self.platform_session_id) == self.platform_session_id:
|
|
1152
|
+
self.workbench_session_id = str(response["workbenchSession"]["id"])
|
|
1147
1153
|
return WorkbenchRun(self.client, self.platform_session_id, response)
|
|
1148
1154
|
|
|
1149
1155
|
def bash(self, body: WorkbenchBashInput) -> WorkbenchRun:
|
|
@@ -1152,10 +1158,11 @@ class SessionSandbox:
|
|
|
1152
1158
|
"sessionId": self.platform_session_id,
|
|
1153
1159
|
"workbenchSessionId": body.get("workbenchSessionId") or self.ensure_session_id(),
|
|
1154
1160
|
}
|
|
1155
|
-
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"))
|
|
1156
1162
|
if body.get("background") is not True:
|
|
1157
1163
|
response = _wait_for_workbench_response(self.client, request, response)
|
|
1158
|
-
self.
|
|
1164
|
+
if _execution_session_id(response, self.platform_session_id) == self.platform_session_id:
|
|
1165
|
+
self.workbench_session_id = str(response["workbenchSession"]["id"])
|
|
1159
1166
|
return WorkbenchRun(self.client, self.platform_session_id, response)
|
|
1160
1167
|
|
|
1161
1168
|
def destroy(self) -> WorkbenchSessionDestroyResponse:
|
|
@@ -1184,7 +1191,7 @@ class WorkbenchResource:
|
|
|
1184
1191
|
return self.client.request("GET", "/v1/workbench/models")
|
|
1185
1192
|
|
|
1186
1193
|
def run(self, body: dict[str, Any]):
|
|
1187
|
-
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")))
|
|
1188
1195
|
if isinstance(response.get("operation"), dict):
|
|
1189
1196
|
operation = response["operation"]
|
|
1190
1197
|
completed = self.wait_operation(body["sessionId"], operation["workbenchSessionId"], operation["operationId"])
|
|
@@ -1194,7 +1201,7 @@ class WorkbenchResource:
|
|
|
1194
1201
|
return response
|
|
1195
1202
|
|
|
1196
1203
|
def bash(self, body: dict[str, Any]):
|
|
1197
|
-
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")))
|
|
1198
1205
|
if body.get("background") is not True:
|
|
1199
1206
|
return _wait_for_workbench_response(self.client, body, response)
|
|
1200
1207
|
return response
|
|
@@ -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()
|
|
@@ -63,6 +63,68 @@ 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
|
+
|
|
66
128
|
def test_workbench_lost_acknowledgement_keeps_upload_and_artifact_identity(self):
|
|
67
129
|
bodies = []
|
|
68
130
|
def transport(method, path, headers, body, query, timeout):
|
|
@@ -143,6 +205,48 @@ class StacklinkClientTests(unittest.TestCase):
|
|
|
143
205
|
self.assertEqual(calls[2][2], {'revision': 2})
|
|
144
206
|
|
|
145
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
|
+
|
|
146
250
|
def test_workbench_approval_returns_to_caller_before_decision(self):
|
|
147
251
|
for operation in ("run_code", "bash"):
|
|
148
252
|
for admission_status in ("waiting_for_approval", "queued"):
|
|
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
|