quartermaster-code-runner 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. quartermaster_code_runner/__init__.py +38 -0
  2. quartermaster_code_runner/app.py +269 -0
  3. quartermaster_code_runner/config.py +175 -0
  4. quartermaster_code_runner/errors.py +88 -0
  5. quartermaster_code_runner/execution.py +231 -0
  6. quartermaster_code_runner/images.py +397 -0
  7. quartermaster_code_runner/runtime/bun/Dockerfile +22 -0
  8. quartermaster_code_runner/runtime/bun/completions.json +34 -0
  9. quartermaster_code_runner/runtime/bun/entrypoint.sh +32 -0
  10. quartermaster_code_runner/runtime/bun/sdk.ts +87 -0
  11. quartermaster_code_runner/runtime/deno/Dockerfile +22 -0
  12. quartermaster_code_runner/runtime/deno/completions.json +34 -0
  13. quartermaster_code_runner/runtime/deno/entrypoint.sh +32 -0
  14. quartermaster_code_runner/runtime/deno/sdk.ts +88 -0
  15. quartermaster_code_runner/runtime/go/Dockerfile +18 -0
  16. quartermaster_code_runner/runtime/go/completions.json +22 -0
  17. quartermaster_code_runner/runtime/go/entrypoint.sh +50 -0
  18. quartermaster_code_runner/runtime/go/sdk.go +101 -0
  19. quartermaster_code_runner/runtime/node/Dockerfile +31 -0
  20. quartermaster_code_runner/runtime/node/completions.json +34 -0
  21. quartermaster_code_runner/runtime/node/entrypoint.sh +33 -0
  22. quartermaster_code_runner/runtime/node/mcp-client.js +274 -0
  23. quartermaster_code_runner/runtime/node/sdk.js +109 -0
  24. quartermaster_code_runner/runtime/python/Dockerfile +42 -0
  25. quartermaster_code_runner/runtime/python/completions.json +34 -0
  26. quartermaster_code_runner/runtime/python/entrypoint.sh +30 -0
  27. quartermaster_code_runner/runtime/python/mcp-client.py +276 -0
  28. quartermaster_code_runner/runtime/python/sdk.py +103 -0
  29. quartermaster_code_runner/runtime/rust/Cargo.toml.default +9 -0
  30. quartermaster_code_runner/runtime/rust/Dockerfile +27 -0
  31. quartermaster_code_runner/runtime/rust/completions.json +34 -0
  32. quartermaster_code_runner/runtime/rust/entrypoint.sh +38 -0
  33. quartermaster_code_runner/runtime/rust/sdk/Cargo.toml +9 -0
  34. quartermaster_code_runner/runtime/rust/sdk/src/lib.rs +149 -0
  35. quartermaster_code_runner/schemas.py +154 -0
  36. quartermaster_code_runner/security.py +81 -0
  37. quartermaster_code_runner-0.0.1.dist-info/METADATA +322 -0
  38. quartermaster_code_runner-0.0.1.dist-info/RECORD +40 -0
  39. quartermaster_code_runner-0.0.1.dist-info/WHEEL +4 -0
  40. quartermaster_code_runner-0.0.1.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MCP Stdio Client - Executes MCP server commands and calls tools via stdio transport
4
+
5
+ Environment variables:
6
+ - MCP_COMMAND: The command to start the MCP server (e.g., "uvx mcp-server-fetch")
7
+ - MCP_OPERATION: The operation to perform: "list_tools" or "call_tool" (default: "call_tool")
8
+ - MCP_TOOL_NAME: The name of the tool to call (required for call_tool operation)
9
+ - MCP_TOOL_ARGUMENTS: JSON string of arguments to pass to the tool
10
+ - MCP_ENV_*: Additional environment variables to pass to the MCP server (prefix stripped)
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ from typing import Any
19
+
20
+ MCP_PROTOCOL_VERSION = "2024-11-05"
21
+ INITIALIZE_TIMEOUT = 60 # 60 seconds for slow package resolution
22
+ TOOL_CALL_TIMEOUT = 120
23
+
24
+
25
+ class McpStdioClient:
26
+ def __init__(self, command: str, server_env: dict[str, str] | None = None):
27
+ self.command = command
28
+ self.server_env = server_env or {}
29
+ self.process: subprocess.Popen | None = None
30
+ self.message_id = 0
31
+ self.pending_requests: dict[int, dict] = {}
32
+ self.initialized = False
33
+ self._lock = threading.Lock()
34
+ self._response_event = threading.Event()
35
+ self._reader_thread: threading.Thread | None = None
36
+ self._stderr_thread: threading.Thread | None = None
37
+ self._exit_thread: threading.Thread | None = None
38
+ self._last_response: dict | None = None
39
+ self._server_stderr_lines: list[str] = []
40
+ self._process_exited = False
41
+ self._exit_code: int | None = None
42
+
43
+ def start(self):
44
+ env = os.environ.copy()
45
+ env.update(self.server_env)
46
+
47
+ for key in ["MCP_COMMAND", "MCP_TOOL_NAME", "MCP_TOOL_ARGUMENTS"]:
48
+ env.pop(key, None)
49
+
50
+ self.process = subprocess.Popen(
51
+ self.command,
52
+ shell=True,
53
+ stdin=subprocess.PIPE,
54
+ stdout=subprocess.PIPE,
55
+ stderr=subprocess.PIPE,
56
+ env=env,
57
+ text=True,
58
+ bufsize=1,
59
+ )
60
+
61
+ self._reader_thread = threading.Thread(target=self._read_responses, daemon=True)
62
+ self._reader_thread.start()
63
+ self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
64
+ self._stderr_thread.start()
65
+ self._exit_thread = threading.Thread(target=self._monitor_exit, daemon=True)
66
+ self._exit_thread.start()
67
+
68
+ def _monitor_exit(self):
69
+ if not self.process:
70
+ return
71
+ self._exit_code = self.process.wait()
72
+ self._process_exited = True
73
+ self._response_event.set()
74
+
75
+ def _read_stderr(self):
76
+ if not self.process or not self.process.stderr:
77
+ return
78
+ for line in self.process.stderr:
79
+ line = line.rstrip("\n")
80
+ self._server_stderr_lines.append(line)
81
+ print(f"[MCP Server] {line}", file=sys.stderr)
82
+
83
+ def _read_responses(self):
84
+ if not self.process or not self.process.stdout:
85
+ return
86
+
87
+ for line in self.process.stdout:
88
+ line = line.strip()
89
+ if not line:
90
+ continue
91
+ try:
92
+ message = json.loads(line)
93
+ self._handle_message(message)
94
+ except json.JSONDecodeError:
95
+ print(f"[MCP Server stdout] {line}", file=sys.stderr)
96
+
97
+ def _handle_message(self, message: dict):
98
+ msg_id = message.get("id")
99
+ if msg_id is not None and msg_id in self.pending_requests:
100
+ with self._lock:
101
+ self._last_response = message
102
+ self._response_event.set()
103
+
104
+ def _send_request(
105
+ self, method: str, params: dict | None = None, timeout: int = 30
106
+ ) -> Any:
107
+ self.message_id += 1
108
+ request_id = self.message_id
109
+
110
+ request = {
111
+ "jsonrpc": "2.0",
112
+ "id": request_id,
113
+ "method": method,
114
+ "params": params or {},
115
+ }
116
+
117
+ with self._lock:
118
+ self.pending_requests[request_id] = {}
119
+ self._response_event.clear()
120
+ self._last_response = None
121
+
122
+ if self._process_exited:
123
+ raise RuntimeError(
124
+ f"MCP server exited with code {self._exit_code} before {method} could be sent"
125
+ )
126
+
127
+ if self.process and self.process.stdin:
128
+ self.process.stdin.write(json.dumps(request) + "\n")
129
+ self.process.stdin.flush()
130
+
131
+ if not self._response_event.wait(timeout):
132
+ raise TimeoutError(f"{method} timed out after {timeout}s")
133
+
134
+ if self._process_exited and self._last_response is None:
135
+ stderr_tail = "\n".join(self._server_stderr_lines[-20:])
136
+ raise RuntimeError(
137
+ f"MCP server process exited with code {self._exit_code} during {method}.\n"
138
+ f"Server output:\n{stderr_tail}"
139
+ )
140
+
141
+ with self._lock:
142
+ response = self._last_response
143
+ self.pending_requests.pop(request_id, None)
144
+
145
+ if response and "error" in response:
146
+ error = response["error"]
147
+ raise Exception(error.get("message", str(error)))
148
+
149
+ return response.get("result") if response else None
150
+
151
+ def _send_notification(self, method: str, params: dict | None = None):
152
+ notification = {
153
+ "jsonrpc": "2.0",
154
+ "method": method,
155
+ "params": params or {},
156
+ }
157
+
158
+ if self.process and self.process.stdin:
159
+ self.process.stdin.write(json.dumps(notification) + "\n")
160
+ self.process.stdin.flush()
161
+
162
+ def initialize(self) -> dict:
163
+ result = self._send_request(
164
+ "initialize",
165
+ {
166
+ "protocolVersion": MCP_PROTOCOL_VERSION,
167
+ "capabilities": {},
168
+ "clientInfo": {
169
+ "name": "quartermaster-code-runner",
170
+ "version": "1.0.0",
171
+ },
172
+ },
173
+ timeout=INITIALIZE_TIMEOUT,
174
+ )
175
+
176
+ self._send_notification("notifications/initialized")
177
+ self.initialized = True
178
+ return result
179
+
180
+ def list_tools(self) -> list[dict]:
181
+ result = self._send_request("tools/list", {})
182
+ return result.get("tools", []) if result else []
183
+
184
+ def call_tool(self, name: str, arguments: dict | None = None) -> str:
185
+ result = self._send_request(
186
+ "tools/call",
187
+ {"name": name, "arguments": arguments or {}},
188
+ timeout=TOOL_CALL_TIMEOUT,
189
+ )
190
+
191
+ content = result.get("content", []) if result else []
192
+ text_parts = []
193
+
194
+ for item in content:
195
+ if item.get("type") == "text":
196
+ text_parts.append(item.get("text", ""))
197
+ elif item.get("type") == "image":
198
+ text_parts.append(f"[Image: {item.get('mimeType', 'unknown')}]")
199
+ elif item.get("type") == "resource":
200
+ text_parts.append(f"[Resource: {item.get('uri', 'unknown')}]")
201
+
202
+ return "\n".join(text_parts)
203
+
204
+ def close(self):
205
+ if self.process:
206
+ self.process.terminate()
207
+ try:
208
+ self.process.wait(timeout=5)
209
+ except subprocess.TimeoutExpired:
210
+ self.process.kill()
211
+
212
+
213
+ def main():
214
+ command = os.environ.get("MCP_COMMAND")
215
+ operation = os.environ.get("MCP_OPERATION", "call_tool")
216
+ tool_name = os.environ.get("MCP_TOOL_NAME")
217
+ tool_arguments_json = os.environ.get("MCP_TOOL_ARGUMENTS", "{}")
218
+
219
+ if not command:
220
+ print(
221
+ "Error: MCP_COMMAND environment variable is required",
222
+ file=sys.stderr,
223
+ )
224
+ sys.exit(1)
225
+
226
+ if operation == "call_tool" and not tool_name:
227
+ print(
228
+ "Error: MCP_TOOL_NAME environment variable is required for call_tool operation",
229
+ file=sys.stderr,
230
+ )
231
+ sys.exit(1)
232
+
233
+ try:
234
+ tool_arguments = json.loads(tool_arguments_json)
235
+ except json.JSONDecodeError:
236
+ print("Error: MCP_TOOL_ARGUMENTS must be valid JSON", file=sys.stderr)
237
+ sys.exit(1)
238
+
239
+ server_env = {}
240
+ for key, value in os.environ.items():
241
+ if key.startswith("MCP_ENV_"):
242
+ server_env[key[8:]] = value
243
+
244
+ client = McpStdioClient(command, server_env)
245
+
246
+ try:
247
+ print(f"Starting MCP server: {command}", file=sys.stderr)
248
+ client.start()
249
+
250
+ print("Initializing MCP connection...", file=sys.stderr)
251
+ client.initialize()
252
+
253
+ if operation == "list_tools":
254
+ print("Listing tools...", file=sys.stderr)
255
+ tools = client.list_tools()
256
+ print(json.dumps(tools))
257
+ else:
258
+ print(f"Calling tool: {tool_name}", file=sys.stderr)
259
+ result = client.call_tool(tool_name or "", tool_arguments)
260
+ print(result)
261
+
262
+ client.close()
263
+ sys.exit(0)
264
+ except Exception as error:
265
+ print(f"MCP Error: {error}", file=sys.stderr)
266
+ if client._server_stderr_lines:
267
+ print("--- MCP Server stderr ---", file=sys.stderr)
268
+ for line in client._server_stderr_lines:
269
+ print(line, file=sys.stderr)
270
+ print("--- End server stderr ---", file=sys.stderr)
271
+ client.close()
272
+ sys.exit(1)
273
+
274
+
275
+ if __name__ == "__main__":
276
+ main()
@@ -0,0 +1,103 @@
1
+ """quartermaster-code-runner SDK for Python runtime."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ _METADATA_FILE = "/metadata/.quartermaster_metadata.json"
9
+
10
+
11
+ def set_metadata(data):
12
+ """
13
+ Set the result metadata to be returned to the backend.
14
+
15
+ This separates structured results from stdout/stderr logs.
16
+
17
+ Args:
18
+ data: Any JSON-serializable data (dict, list, str, int, etc.)
19
+
20
+ Example:
21
+ from sdk import set_metadata
22
+
23
+ result = {"status": "success", "count": 42}
24
+ set_metadata(result)
25
+ """
26
+ with open(_METADATA_FILE, "w") as f:
27
+ json.dump(data, f)
28
+
29
+
30
+ def get_metadata():
31
+ """
32
+ Get previously set metadata (useful for reading/modifying).
33
+
34
+ Returns:
35
+ The previously set metadata, or None if not set.
36
+ """
37
+ if not os.path.exists(_METADATA_FILE):
38
+ return None
39
+ with open(_METADATA_FILE, "r") as f:
40
+ return json.load(f)
41
+
42
+
43
+ def load_file(path):
44
+ """Load a file from the flow's environment.
45
+
46
+ Only available during flow execution, not test runs.
47
+
48
+ Args:
49
+ path: Path to the file within the environment.
50
+
51
+ Returns:
52
+ The file content as a string.
53
+
54
+ Example:
55
+ from sdk import load_file
56
+
57
+ content = load_file("data/config.json")
58
+ """
59
+ webdav_url = os.environ.get("QM_WEBDAV_URL")
60
+ if not webdav_url:
61
+ raise RuntimeError(
62
+ "load_file() is only available during flow execution. "
63
+ "For test runs, use mounted environments instead."
64
+ )
65
+ url = webdav_url.rstrip("/") + "/" + path.lstrip("/")
66
+ req = urllib.request.Request(url, method="GET")
67
+ try:
68
+ with urllib.request.urlopen(req) as resp:
69
+ return resp.read().decode("utf-8")
70
+ except urllib.error.HTTPError as e:
71
+ if e.code == 404:
72
+ raise FileNotFoundError(f"File not found: {path}")
73
+ raise RuntimeError(f"Failed to load file: {e}")
74
+
75
+
76
+ def save_file(path, content):
77
+ """Save a file to the flow's environment.
78
+
79
+ Only available during flow execution, not test runs.
80
+
81
+ Args:
82
+ path: Path to the file within the environment.
83
+ content: The file content to save.
84
+
85
+ Example:
86
+ from sdk import save_file
87
+
88
+ save_file("output/result.txt", "Hello, world!")
89
+ """
90
+ webdav_url = os.environ.get("QM_WEBDAV_URL")
91
+ if not webdav_url:
92
+ raise RuntimeError(
93
+ "save_file() is only available during flow execution. "
94
+ "For test runs, use mounted environments instead."
95
+ )
96
+ url = webdav_url.rstrip("/") + "/" + path.lstrip("/")
97
+ data = content.encode("utf-8")
98
+ req = urllib.request.Request(url, data=data, method="PUT")
99
+ req.add_header("Content-Type", "application/octet-stream")
100
+ try:
101
+ urllib.request.urlopen(req)
102
+ except urllib.error.HTTPError as e:
103
+ raise RuntimeError(f"Failed to save file: {e}")
@@ -0,0 +1,9 @@
1
+ [package]
2
+ name = "user_code"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ sdk = { path = "/app/sdk" }
8
+ serde = { version = "1.0", features = ["derive"] }
9
+ serde_json = "1.0"
@@ -0,0 +1,27 @@
1
+ FROM rust:1.87-slim
2
+
3
+ LABEL qm.name="Rust"
4
+ LABEL qm.description="Rust 1.87 with Cargo"
5
+ LABEL qm.default_entrypoint="cargo run"
6
+ LABEL qm.file_extension=".rs"
7
+ LABEL qm.main_file="src/main.rs"
8
+
9
+ WORKDIR /app
10
+
11
+ RUN apt-get update && \
12
+ apt-get install -y tar && \
13
+ rm -rf /var/lib/apt/lists/* && \
14
+ useradd --uid 1001 --no-create-home --shell /bin/sh runner && \
15
+ mkdir -p /home/runner/.cargo && \
16
+ chown -R runner:runner /home/runner
17
+
18
+ # Pre-compile SDK with serde to cache dependencies
19
+ COPY sdk/ /app/sdk/
20
+ RUN cd /app/sdk && cargo build --release && \
21
+ chown -R runner:runner /app/sdk
22
+
23
+ COPY Cargo.toml.default .
24
+ COPY entrypoint.sh .
25
+ RUN chmod +x entrypoint.sh
26
+
27
+ ENTRYPOINT ["/app/entrypoint.sh"]
@@ -0,0 +1,34 @@
1
+ [
2
+ {
3
+ "caption": "use sdk::set_metadata",
4
+ "value": "use sdk::set_metadata;",
5
+ "meta": "import",
6
+ "score": 1000
7
+ },
8
+ {
9
+ "caption": "use sdk::get_metadata",
10
+ "value": "use sdk::get_metadata;",
11
+ "meta": "import",
12
+ "score": 1000
13
+ },
14
+ {
15
+ "caption": "use sdk::{set_metadata, get_metadata}",
16
+ "value": "use sdk::{set_metadata, get_metadata};",
17
+ "meta": "import",
18
+ "score": 1000
19
+ },
20
+ {
21
+ "caption": "set_metadata",
22
+ "snippet": "set_metadata(&$1).unwrap()",
23
+ "meta": "sdk",
24
+ "score": 1000,
25
+ "docText": "Set structured result metadata to be returned to the backend. Accepts any Serialize type."
26
+ },
27
+ {
28
+ "caption": "get_metadata",
29
+ "snippet": "get_metadata::<$1>().unwrap()",
30
+ "meta": "sdk",
31
+ "score": 1000,
32
+ "docText": "Get previously set metadata, deserializing into the specified type. Returns None if not set."
33
+ }
34
+ ]
@@ -0,0 +1,38 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ chown runner:runner /tmp
5
+ chown runner:runner /metadata 2>/dev/null || true
6
+ chown runner:runner /workspace 2>/dev/null || true
7
+
8
+ exec su --preserve-environment -s /bin/sh -c '
9
+ set -e
10
+ cd /workspace
11
+ export HOME=/workspace
12
+ export CARGO_HOME=/workspace/.cargo
13
+ mkdir -p $CARGO_HOME
14
+
15
+ # Decode code to default filename
16
+ if [ -n "$ENCODED_CODE" ]; then
17
+ echo "$ENCODED_CODE" | base64 -d > main.rs
18
+ fi
19
+
20
+ # Extract additional files if provided
21
+ if [ -n "$ENCODED_FILES" ]; then
22
+ echo "$ENCODED_FILES" | base64 -d | tar -xz
23
+ fi
24
+
25
+ # Copy default Cargo.toml if user did not provide one
26
+ if [ ! -f Cargo.toml ]; then
27
+ cp /app/Cargo.toml.default Cargo.toml
28
+ fi
29
+
30
+ # Execute custom entrypoint or default
31
+ if [ -n "$CUSTOM_ENTRYPOINT" ]; then
32
+ exec sh -c "$CUSTOM_ENTRYPOINT"
33
+ else
34
+ mkdir -p src
35
+ mv main.rs src/main.rs
36
+ exec cargo run --release -q
37
+ fi
38
+ ' runner
@@ -0,0 +1,9 @@
1
+ [package]
2
+ name = "sdk"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ serde = { version = "1.0", features = ["derive"] }
8
+ serde_json = "1.0"
9
+ ureq = "2"
@@ -0,0 +1,149 @@
1
+ //! quartermaster-code-runner SDK for Rust runtime.
2
+ //!
3
+ //! # Example
4
+ //!
5
+ //! ```rust
6
+ //! use sdk::set_metadata;
7
+ //! use serde::Serialize;
8
+ //!
9
+ //! #[derive(Serialize)]
10
+ //! struct Result {
11
+ //! status: String,
12
+ //! count: i32,
13
+ //! }
14
+ //!
15
+ //! fn main() {
16
+ //! let result = Result { status: "success".into(), count: 42 };
17
+ //! set_metadata(&result).unwrap();
18
+ //! }
19
+ //! ```
20
+
21
+ use serde::de::DeserializeOwned;
22
+ use serde::Serialize;
23
+ use std::env;
24
+ use std::fs;
25
+ use std::io;
26
+
27
+ const METADATA_FILE: &str = "/metadata/.quartermaster_metadata.json";
28
+
29
+ /// Set the result metadata to be returned to the backend.
30
+ ///
31
+ /// This separates structured results from stdout/stderr logs.
32
+ ///
33
+ /// # Example
34
+ ///
35
+ /// ```rust
36
+ /// use sdk::set_metadata;
37
+ /// use serde_json::json;
38
+ ///
39
+ ///
40
+ /// set_metadata(&json!({"status": "success", "count": 42})).unwrap();
41
+ /// ```
42
+ pub fn set_metadata<T: Serialize>(data: &T) -> io::Result<()> {
43
+ let json =
44
+ serde_json::to_string(data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
45
+ fs::write(METADATA_FILE, json)
46
+ }
47
+
48
+ /// Set metadata from a raw JSON string.
49
+ ///
50
+ /// Use this if you already have JSON as a string.
51
+ ///
52
+ /// # Example
53
+ ///
54
+ /// ```rust
55
+ /// sdk::set_metadata_raw(r#"{"status": "success"}"#).unwrap();
56
+ /// ```
57
+ pub fn set_metadata_raw(json: &str) -> io::Result<()> {
58
+ fs::write(METADATA_FILE, json)
59
+ }
60
+
61
+ /// Get previously set metadata, deserializing into the specified type.
62
+ ///
63
+ /// Returns `None` if no metadata has been set.
64
+ pub fn get_metadata<T: DeserializeOwned>() -> io::Result<Option<T>> {
65
+ match fs::read_to_string(METADATA_FILE) {
66
+ Ok(content) => {
67
+ let data = serde_json::from_str(&content)
68
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
69
+ Ok(Some(data))
70
+ }
71
+ Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
72
+ Err(e) => Err(e),
73
+ }
74
+ }
75
+
76
+ /// Get previously set metadata as a raw JSON string.
77
+ ///
78
+ /// Returns `None` if no metadata has been set.
79
+ pub fn get_metadata_raw() -> io::Result<Option<String>> {
80
+ match fs::read_to_string(METADATA_FILE) {
81
+ Ok(content) => Ok(Some(content)),
82
+ Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
83
+ Err(e) => Err(e),
84
+ }
85
+ }
86
+
87
+ fn webdav_url(path: &str) -> Result<String, io::Error> {
88
+ let base = env::var("QM_WEBDAV_URL").map_err(|_| {
89
+ io::Error::new(
90
+ io::ErrorKind::NotFound,
91
+ "load_file/save_file is only available during flow execution. \
92
+ For test runs, use mounted environments instead.",
93
+ )
94
+ })?;
95
+ Ok(format!(
96
+ "{}/{}",
97
+ base.trim_end_matches('/'),
98
+ path.trim_start_matches('/')
99
+ ))
100
+ }
101
+
102
+ /// Load a file from the flow's environment.
103
+ ///
104
+ /// Only available during flow execution, not test runs.
105
+ ///
106
+ /// # Example
107
+ ///
108
+ /// ```rust,no_run
109
+ /// let content = sdk::load_file("data/config.json").unwrap();
110
+ /// println!("{}", content);
111
+ /// ```
112
+ pub fn load_file(path: &str) -> io::Result<String> {
113
+ let url = webdav_url(path)?;
114
+ let resp = ureq::get(&url).call().map_err(|e| match &e {
115
+ ureq::Error::Status(404, _) => io::Error::new(
116
+ io::ErrorKind::NotFound,
117
+ format!("File not found: {}", path),
118
+ ),
119
+ _ => io::Error::new(
120
+ io::ErrorKind::Other,
121
+ format!("Failed to load file: {}", e),
122
+ ),
123
+ })?;
124
+ resp.into_string()
125
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))
126
+ }
127
+
128
+ /// Save a file to the flow's environment.
129
+ ///
130
+ /// Only available during flow execution, not test runs.
131
+ ///
132
+ /// # Example
133
+ ///
134
+ /// ```rust,no_run
135
+ /// sdk::save_file("output/result.txt", "Hello, world!").unwrap();
136
+ /// ```
137
+ pub fn save_file(path: &str, content: &str) -> io::Result<()> {
138
+ let url = webdav_url(path)?;
139
+ ureq::put(&url)
140
+ .set("Content-Type", "application/octet-stream")
141
+ .send_string(content)
142
+ .map_err(|e| {
143
+ io::Error::new(
144
+ io::ErrorKind::Other,
145
+ format!("Failed to save file: {}", e),
146
+ )
147
+ })?;
148
+ Ok(())
149
+ }