autoflow-sdk 1.0.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.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: autoflow-sdk
3
+ Version: 1.0.0
4
+ Summary: AutoFlow Python SDK — base utilities for task execution
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.24.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: pydantic>=2.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
14
+ Requires-Dist: respx>=0.21; extra == "dev"
15
+
16
+ # autoflow-sdk — AutoCodeFlow Python SDK
17
+
18
+ AutoCodeFlow 任务执行器(executor-python)侧的 Python SDK:提供任务上下文
19
+ (`TaskContext`)、日志(`ctx.log`)、Admin API 回调客户端
20
+ (`CallbackClient` + `ctx.report_success()` / `ctx.report_failure()`)与
21
+ 通用 HTTP 客户端(`HttpClient` / `AsyncHttpClient`,基于 httpx)。回调契约
22
+ 与 Node.js SDK [`@autocodeflow/sdk`](../autocodeflow-node-sdk/README.md) 完全
23
+ 对齐(第九轮),双端共用 `CallbackItemDto` 字段。平台侧完整说明见
24
+ [docs/sdk-guide.md](../../docs/sdk-guide.md)。
25
+
26
+ ## 安装
27
+
28
+ ```bash
29
+ pip install autoflow-sdk # 公共 PyPI(发布后)
30
+ # 内网私有 registry(apps/registry-pypi):
31
+ pip install --index-url http://<registry-pypi-host>/simple autoflow-sdk
32
+ ```
33
+
34
+ 要求 Python ≥ 3.9;运行时依赖 `httpx`、`pyyaml`、`pydantic>=2`
35
+ (`autoflow_sdk.models` 的协议模型);开发/测试依赖另需
36
+ `pytest`、`pytest-asyncio`、`respx`(见 `[project.optional-dependencies].dev`)。
37
+
38
+ ## Quickstart
39
+
40
+ 任务脚本由执行器以子进程运行,环境变量注入见下表。
41
+ `TaskContext.from_env()` 读取 `EXECUTION_ID` / `TASK_ID` / `TASK_NAME` 与
42
+ 全部 `AUTOFLOW_*` 触发参数(回调凭证三件套除外——它们暴露为专用字段,
43
+ 绝不混入 `ctx.params`):
44
+
45
+ ```python
46
+ # tasks/fetch_data.py
47
+ from autoflow_sdk import TaskContext
48
+
49
+ ctx = TaskContext.from_env()
50
+ ctx.log.info(f"task {ctx.task_id} started, execution={ctx.execution_id}")
51
+
52
+ source_url = ctx.get_param("source_url") # AUTOFLOW_SOURCE_URL
53
+ rows = do_work(source_url)
54
+
55
+ # 主动回调 Admin API(N23 per-execution token)。旧版执行器不注入凭证,
56
+ # 此时 ctx.callback.enabled 为 False,结果仍由执行器统一上报。
57
+ if ctx.callback.enabled:
58
+ ctx.report_success(summary=f"{rows} rows written", duration_ms=1200)
59
+ ```
60
+
61
+ 失败上报(`error` 映射为 `errorMessage` 并截断至 4 KB;`failure_reason`
62
+ 取 admin-api 的 `ExecutionFailureReason` 枚举,默认 `script_error`,非法值
63
+ 抛 `ValueError`):
64
+
65
+ ```python
66
+ try:
67
+ rows = do_work(source_url)
68
+ except Exception as e:
69
+ if ctx.callback.enabled:
70
+ ctx.report_failure(e, failure_reason="script_error")
71
+ raise
72
+ ```
73
+
74
+ ### 底层回调客户端
75
+
76
+ 批量/自定义字段时直接使用 `ctx.callback.report()`,
77
+ `executionId` / `executorAddress` 自动补齐(N27),显式书写的值不会被覆盖:
78
+
79
+ ```python
80
+ ctx.callback.report([
81
+ {"status": "success", "durationMs": 800},
82
+ {"status": "success", "durationMs": 900, "logs": "checkpoint 2"},
83
+ ])
84
+ ```
85
+
86
+ 凭证缺失时任何上报调用抛 `CallbackDisabledError` 并指明缺失变量
87
+ (fail-closed,与 Node `HttpClient.disabledReason` 语义对齐)。
88
+
89
+ ### HttpClient 直用
90
+
91
+ 通用 HTTP 客户端(非回调专用)可独立构造:
92
+
93
+ ```python
94
+ from autoflow_sdk import HttpClient, AsyncHttpClient
95
+
96
+ http = HttpClient(base_url="http://admin-api:3105", timeout=30.0,
97
+ headers={"Authorization": f"Bearer {token}"})
98
+ resp = http.get("/api/tasks") # 返回 httpx.Response
99
+
100
+ async with AsyncHttpClient(base_url=...) as ahttp:
101
+ resp = await ahttp.post("/api/x", json={"a": 1})
102
+ ```
103
+
104
+ ## 执行器注入的环境变量
105
+
106
+ 与 [docs/sdk-guide.md](../../docs/sdk-guide.md) 的注入表逐字一致:
107
+
108
+ | 变量名 | 说明 | 示例值 |
109
+ |--------|------|--------|
110
+ | `TASK_ID` | 当前任务的唯一标识 | `task_abc123` |
111
+ | `TASK_NAME` | 当前任务名称 | `fetch_data` |
112
+ | `EXECUTION_ID` | 本次执行记录的唯一标识 | `exec_xyz789` |
113
+ | `AUTOFLOW_<KEY>` | 触发参数,按参数名转大写后注入 | `AUTOFLOW_SOURCE_URL=https://api.example.com` |
114
+ | `AUTOFLOW_ADMIN_API_URL` | Admin API 基地址(非机密路由信息,N23 起注入) | `AUTOFLOW_ADMIN_API_URL=http://admin-api:3105` |
115
+ | `AUTOFLOW_CALLBACK_TOKEN` | 本次执行的一次性回调 token(`v1.` HMAC,绑定 executionId、随 TTL 过期,N23 起注入) | `AUTOFLOW_CALLBACK_TOKEN=v1.<uuid>.<exp>.<hmac>` |
116
+ | `AUTOFLOW_EXECUTOR_ADDRESS` | 当前执行器注册地址(非机密路由信息,N27 起注入;SDK 经 `ctx.executorAddress`(Node)/ `ctx.executor_address`(Python)暴露并自动填入回调请求) | `AUTOFLOW_EXECUTOR_ADDRESS=executor-node:8002` |
117
+
118
+ ## 回调契约(与 Node SDK 一致)
119
+
120
+ `POST {AUTOFLOW_ADMIN_API_URL}/api/executions/callback`,请求体为
121
+ `CallbackItemDto[]`:`executionId` / `status: success|failed` /
122
+ `executorAddress` / `logs` / `errorMessage` / `failureReason` /
123
+ `durationMs`。per-execution token 仅授权本 `executionId` 的回调,越权或
124
+ 过期一律 401(fail-closed);执行器共享 token 绝不进入任务子进程(SEC-01)。
125
+
126
+ ## 版本与发布
127
+
128
+ - 版本策略:与 `@autocodeflow/sdk`(npm)、`autocodeflow-mcp-server` 走
129
+ **lockstep** 单版本线,当前 `1.0.0`。
130
+ - 发布管道:[.github/workflows/release.yml](../../.github/workflows/release.yml)。
131
+ push tag `vX.Y.Z` 触发:版本一致性守卫(tag 必须等于本包
132
+ `pyproject.toml` version 与 `autoflow_sdk.__version__`,不一致直接
133
+ fail)→ `publish-pypi` job(python 3.12,`python -m build` +
134
+ `pypa/gh-action-pypi-publish`)。
135
+ - 凭证:GitHub secret `PYPI_API_TOKEN`(pypi.org API token);如改用
136
+ PyPI Trusted Publishing(OIDC)则删 workflow 中 `password` 行。
137
+ - 元数据单一来源:`pyproject.toml`(`setup.py` 仅留 `setup()`)。
138
+ - 本地演练(不真发布):
139
+
140
+ ```bash
141
+ cd packages/autoflow-sdk
142
+ python -m pip install build && python -m build --wheel # 产物在 dist/(已 gitignore)
143
+ ```
144
+
145
+ - 发布流程与矩阵说明见
146
+ [docs/sdk-guide.md「SDK 矩阵」](../../docs/sdk-guide.md)。
@@ -0,0 +1,131 @@
1
+ # autoflow-sdk — AutoCodeFlow Python SDK
2
+
3
+ AutoCodeFlow 任务执行器(executor-python)侧的 Python SDK:提供任务上下文
4
+ (`TaskContext`)、日志(`ctx.log`)、Admin API 回调客户端
5
+ (`CallbackClient` + `ctx.report_success()` / `ctx.report_failure()`)与
6
+ 通用 HTTP 客户端(`HttpClient` / `AsyncHttpClient`,基于 httpx)。回调契约
7
+ 与 Node.js SDK [`@autocodeflow/sdk`](../autocodeflow-node-sdk/README.md) 完全
8
+ 对齐(第九轮),双端共用 `CallbackItemDto` 字段。平台侧完整说明见
9
+ [docs/sdk-guide.md](../../docs/sdk-guide.md)。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ pip install autoflow-sdk # 公共 PyPI(发布后)
15
+ # 内网私有 registry(apps/registry-pypi):
16
+ pip install --index-url http://<registry-pypi-host>/simple autoflow-sdk
17
+ ```
18
+
19
+ 要求 Python ≥ 3.9;运行时依赖 `httpx`、`pyyaml`、`pydantic>=2`
20
+ (`autoflow_sdk.models` 的协议模型);开发/测试依赖另需
21
+ `pytest`、`pytest-asyncio`、`respx`(见 `[project.optional-dependencies].dev`)。
22
+
23
+ ## Quickstart
24
+
25
+ 任务脚本由执行器以子进程运行,环境变量注入见下表。
26
+ `TaskContext.from_env()` 读取 `EXECUTION_ID` / `TASK_ID` / `TASK_NAME` 与
27
+ 全部 `AUTOFLOW_*` 触发参数(回调凭证三件套除外——它们暴露为专用字段,
28
+ 绝不混入 `ctx.params`):
29
+
30
+ ```python
31
+ # tasks/fetch_data.py
32
+ from autoflow_sdk import TaskContext
33
+
34
+ ctx = TaskContext.from_env()
35
+ ctx.log.info(f"task {ctx.task_id} started, execution={ctx.execution_id}")
36
+
37
+ source_url = ctx.get_param("source_url") # AUTOFLOW_SOURCE_URL
38
+ rows = do_work(source_url)
39
+
40
+ # 主动回调 Admin API(N23 per-execution token)。旧版执行器不注入凭证,
41
+ # 此时 ctx.callback.enabled 为 False,结果仍由执行器统一上报。
42
+ if ctx.callback.enabled:
43
+ ctx.report_success(summary=f"{rows} rows written", duration_ms=1200)
44
+ ```
45
+
46
+ 失败上报(`error` 映射为 `errorMessage` 并截断至 4 KB;`failure_reason`
47
+ 取 admin-api 的 `ExecutionFailureReason` 枚举,默认 `script_error`,非法值
48
+ 抛 `ValueError`):
49
+
50
+ ```python
51
+ try:
52
+ rows = do_work(source_url)
53
+ except Exception as e:
54
+ if ctx.callback.enabled:
55
+ ctx.report_failure(e, failure_reason="script_error")
56
+ raise
57
+ ```
58
+
59
+ ### 底层回调客户端
60
+
61
+ 批量/自定义字段时直接使用 `ctx.callback.report()`,
62
+ `executionId` / `executorAddress` 自动补齐(N27),显式书写的值不会被覆盖:
63
+
64
+ ```python
65
+ ctx.callback.report([
66
+ {"status": "success", "durationMs": 800},
67
+ {"status": "success", "durationMs": 900, "logs": "checkpoint 2"},
68
+ ])
69
+ ```
70
+
71
+ 凭证缺失时任何上报调用抛 `CallbackDisabledError` 并指明缺失变量
72
+ (fail-closed,与 Node `HttpClient.disabledReason` 语义对齐)。
73
+
74
+ ### HttpClient 直用
75
+
76
+ 通用 HTTP 客户端(非回调专用)可独立构造:
77
+
78
+ ```python
79
+ from autoflow_sdk import HttpClient, AsyncHttpClient
80
+
81
+ http = HttpClient(base_url="http://admin-api:3105", timeout=30.0,
82
+ headers={"Authorization": f"Bearer {token}"})
83
+ resp = http.get("/api/tasks") # 返回 httpx.Response
84
+
85
+ async with AsyncHttpClient(base_url=...) as ahttp:
86
+ resp = await ahttp.post("/api/x", json={"a": 1})
87
+ ```
88
+
89
+ ## 执行器注入的环境变量
90
+
91
+ 与 [docs/sdk-guide.md](../../docs/sdk-guide.md) 的注入表逐字一致:
92
+
93
+ | 变量名 | 说明 | 示例值 |
94
+ |--------|------|--------|
95
+ | `TASK_ID` | 当前任务的唯一标识 | `task_abc123` |
96
+ | `TASK_NAME` | 当前任务名称 | `fetch_data` |
97
+ | `EXECUTION_ID` | 本次执行记录的唯一标识 | `exec_xyz789` |
98
+ | `AUTOFLOW_<KEY>` | 触发参数,按参数名转大写后注入 | `AUTOFLOW_SOURCE_URL=https://api.example.com` |
99
+ | `AUTOFLOW_ADMIN_API_URL` | Admin API 基地址(非机密路由信息,N23 起注入) | `AUTOFLOW_ADMIN_API_URL=http://admin-api:3105` |
100
+ | `AUTOFLOW_CALLBACK_TOKEN` | 本次执行的一次性回调 token(`v1.` HMAC,绑定 executionId、随 TTL 过期,N23 起注入) | `AUTOFLOW_CALLBACK_TOKEN=v1.<uuid>.<exp>.<hmac>` |
101
+ | `AUTOFLOW_EXECUTOR_ADDRESS` | 当前执行器注册地址(非机密路由信息,N27 起注入;SDK 经 `ctx.executorAddress`(Node)/ `ctx.executor_address`(Python)暴露并自动填入回调请求) | `AUTOFLOW_EXECUTOR_ADDRESS=executor-node:8002` |
102
+
103
+ ## 回调契约(与 Node SDK 一致)
104
+
105
+ `POST {AUTOFLOW_ADMIN_API_URL}/api/executions/callback`,请求体为
106
+ `CallbackItemDto[]`:`executionId` / `status: success|failed` /
107
+ `executorAddress` / `logs` / `errorMessage` / `failureReason` /
108
+ `durationMs`。per-execution token 仅授权本 `executionId` 的回调,越权或
109
+ 过期一律 401(fail-closed);执行器共享 token 绝不进入任务子进程(SEC-01)。
110
+
111
+ ## 版本与发布
112
+
113
+ - 版本策略:与 `@autocodeflow/sdk`(npm)、`autocodeflow-mcp-server` 走
114
+ **lockstep** 单版本线,当前 `1.0.0`。
115
+ - 发布管道:[.github/workflows/release.yml](../../.github/workflows/release.yml)。
116
+ push tag `vX.Y.Z` 触发:版本一致性守卫(tag 必须等于本包
117
+ `pyproject.toml` version 与 `autoflow_sdk.__version__`,不一致直接
118
+ fail)→ `publish-pypi` job(python 3.12,`python -m build` +
119
+ `pypa/gh-action-pypi-publish`)。
120
+ - 凭证:GitHub secret `PYPI_API_TOKEN`(pypi.org API token);如改用
121
+ PyPI Trusted Publishing(OIDC)则删 workflow 中 `password` 行。
122
+ - 元数据单一来源:`pyproject.toml`(`setup.py` 仅留 `setup()`)。
123
+ - 本地演练(不真发布):
124
+
125
+ ```bash
126
+ cd packages/autoflow-sdk
127
+ python -m pip install build && python -m build --wheel # 产物在 dist/(已 gitignore)
128
+ ```
129
+
130
+ - 发布流程与矩阵说明见
131
+ [docs/sdk-guide.md「SDK 矩阵」](../../docs/sdk-guide.md)。
@@ -0,0 +1,14 @@
1
+ """AutoFlow SDK — Python base utilities."""
2
+ from .context import TaskContext
3
+ from .logger import get_logger
4
+ from .http import HttpClient, AsyncHttpClient
5
+ from .callback import CallbackClient, CallbackDisabledError
6
+ from .result import TaskResult
7
+ from .models import ExecuteRequest, ExecuteResult, TaskConfig
8
+
9
+ __version__ = "1.0.0"
10
+ __all__ = [
11
+ "TaskContext", "get_logger", "HttpClient", "AsyncHttpClient", "TaskResult",
12
+ "ExecuteRequest", "ExecuteResult", "TaskConfig",
13
+ "CallbackClient", "CallbackDisabledError",
14
+ ]
@@ -0,0 +1,179 @@
1
+ """Per-execution Admin API callback client (N23/N27 parity with node SDK).
2
+
3
+ Since round 7/8 the executors inject three variables into task subprocesses:
4
+
5
+ - ``AUTOFLOW_CALLBACK_TOKEN`` — a one-shot ``v1.<executionId>.<exp>.<hmac>``
6
+ token bound to THIS execution (never the executor shared token, SEC-01);
7
+ - ``AUTOFLOW_ADMIN_API_URL`` — Admin API base URL (non-secret routing info);
8
+ - ``AUTOFLOW_EXECUTOR_ADDRESS`` — the address this executor registered with
9
+ (N27), stamped onto callback items automatically.
10
+
11
+ Task code uses them via ``TaskContext``::
12
+
13
+ ctx = TaskContext.from_env()
14
+ if ctx.callback.enabled:
15
+ ctx.report_success(summary="3 rows written")
16
+ # or, on failure:
17
+ ctx.report_failure(ValueError("upstream 503"))
18
+
19
+ The client is ENABLED only when all three credentials are present. When
20
+ disabled, construction still succeeds (so ``ctx.callback.enabled`` can be
21
+ checked), but any report attempt raises :class:`CallbackDisabledError`
22
+ naming the missing variables — the same contract as the node SDK's
23
+ ``HttpClient.disabledReason``.
24
+
25
+ Payload shape follows admin-api's ``CallbackItemDto``
26
+ (apps/admin-api/src/modules/task/dto/execution-callback.dto.ts):
27
+ ``POST {admin_api_url}/api/executions/callback`` with
28
+ ``Authorization: Bearer <token>`` and a JSON array body of
29
+ ``{executionId, status, executorAddress, logs?, errorMessage?,
30
+ failureReason?, durationMs?}`` items.
31
+ """
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ import httpx
35
+
36
+ # CallbackItemDto field constraints (keep in sync with the admin DTO).
37
+ ERROR_MESSAGE_MAX_LENGTH = 4096
38
+ LOGS_MAX_LENGTH = 512_000
39
+
40
+ # ExecutionFailureReason enum values accepted by the DTO validation.
41
+ VALID_FAILURE_REASONS = frozenset({
42
+ "package_fetch_failed",
43
+ "script_error",
44
+ "timeout",
45
+ "executor_offline",
46
+ "executor_restart",
47
+ "killed",
48
+ "unknown",
49
+ })
50
+
51
+
52
+ class CallbackDisabledError(RuntimeError):
53
+ """Raised when a callback is attempted without full credentials."""
54
+
55
+
56
+ class CallbackClient:
57
+ """Synchronous client for ``POST /api/executions/callback``."""
58
+
59
+ def __init__(
60
+ self,
61
+ admin_api_url: Optional[str] = None,
62
+ token: Optional[str] = None,
63
+ executor_address: Optional[str] = None,
64
+ execution_id: Optional[str] = None,
65
+ timeout: float = 10.0,
66
+ ) -> None:
67
+ self.admin_api_url = (admin_api_url or "").rstrip("/")
68
+ self.token = token or ""
69
+ self.executor_address = executor_address or ""
70
+ self.execution_id = execution_id or ""
71
+ self.timeout = timeout
72
+
73
+ missing = []
74
+ if not self.admin_api_url:
75
+ missing.append("AUTOFLOW_ADMIN_API_URL")
76
+ if not self.token:
77
+ missing.append("AUTOFLOW_CALLBACK_TOKEN")
78
+ if not self.executor_address:
79
+ missing.append("AUTOFLOW_EXECUTOR_ADDRESS")
80
+ #: Whether all three callback credentials are present.
81
+ self.enabled = not missing
82
+ #: Reason for being disabled (populated only when ``enabled`` is False).
83
+ self.disabled_reason: Optional[str] = None
84
+ if not self.enabled:
85
+ self.disabled_reason = (
86
+ "CallbackClient is disabled: Admin API callback credentials are "
87
+ "missing (" + ", ".join(missing) + " were not present in the "
88
+ "environment; older executors never inject them, see SEC-01/N23). "
89
+ "Provide them via TaskContext(callback_token=..., admin_api_url=..., "
90
+ "executor_address=...) if callbacks are required."
91
+ )
92
+
93
+ # ------------------------------------------------------------------ url
94
+
95
+ @property
96
+ def callback_url(self) -> str:
97
+ """Full callback endpoint URL; tolerates a base that already ends in /api."""
98
+ if self.admin_api_url.endswith("/api"):
99
+ return f"{self.admin_api_url}/executions/callback"
100
+ return f"{self.admin_api_url}/api/executions/callback"
101
+
102
+ # ------------------------------------------------------------------ core
103
+
104
+ def report(self, items: List[Dict[str, Any]]) -> Any:
105
+ """POST a batch of CallbackItemDto dicts. Returns the parsed response.
106
+
107
+ Raises CallbackDisabledError without credentials, httpx.HTTPStatusError
108
+ on a non-2xx answer from the Admin API.
109
+ """
110
+ if not self.enabled:
111
+ raise CallbackDisabledError(self.disabled_reason)
112
+ payload = [self._with_defaults(item) for item in items]
113
+ with httpx.Client(timeout=self.timeout, trust_env=False) as client:
114
+ resp = client.post(
115
+ self.callback_url,
116
+ json=payload,
117
+ headers={"Authorization": f"Bearer {self.token}"},
118
+ )
119
+ resp.raise_for_status()
120
+ return resp.json()
121
+
122
+ def _with_defaults(self, item: Dict[str, Any]) -> Dict[str, Any]:
123
+ """Fill executionId / executorAddress on items that omit them (N27)."""
124
+ filled = dict(item)
125
+ if not filled.get("executionId"):
126
+ filled["executionId"] = self.execution_id
127
+ if not filled.get("executorAddress"):
128
+ filled["executorAddress"] = self.executor_address
129
+ return filled
130
+
131
+ # ------------------------------------------------------- convenience
132
+
133
+ def report_success(
134
+ self,
135
+ summary: Optional[str] = None,
136
+ duration_ms: Optional[int] = None,
137
+ ) -> Any:
138
+ """Report status=success for this client's execution."""
139
+ item: Dict[str, Any] = {
140
+ "executionId": self.execution_id,
141
+ "status": "success",
142
+ "executorAddress": self.executor_address,
143
+ }
144
+ if summary:
145
+ item["logs"] = summary[:LOGS_MAX_LENGTH]
146
+ if duration_ms is not None:
147
+ item["durationMs"] = duration_ms
148
+ return self.report([item])
149
+
150
+ def report_failure(
151
+ self,
152
+ error: Any,
153
+ summary: Optional[str] = None,
154
+ duration_ms: Optional[int] = None,
155
+ failure_reason: str = "script_error",
156
+ ) -> Any:
157
+ """Report status=failed for this client's execution.
158
+
159
+ ``error`` (any object) is stringified into ``errorMessage`` and
160
+ truncated to the DTO's 4 KB cap; ``failure_reason`` must be one of
161
+ admin-api's ExecutionFailureReason values (default ``script_error``).
162
+ """
163
+ if failure_reason not in VALID_FAILURE_REASONS:
164
+ raise ValueError(
165
+ f"invalid failure_reason {failure_reason!r}; expected one of "
166
+ + ", ".join(sorted(VALID_FAILURE_REASONS))
167
+ )
168
+ item: Dict[str, Any] = {
169
+ "executionId": self.execution_id,
170
+ "status": "failed",
171
+ "executorAddress": self.executor_address,
172
+ "errorMessage": str(error)[:ERROR_MESSAGE_MAX_LENGTH],
173
+ "failureReason": failure_reason,
174
+ }
175
+ if summary:
176
+ item["logs"] = summary[:LOGS_MAX_LENGTH]
177
+ if duration_ms is not None:
178
+ item["durationMs"] = duration_ms
179
+ return self.report([item])
@@ -0,0 +1,153 @@
1
+ """Task execution context — passed to every task handler."""
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Dict, Optional
4
+ import os
5
+
6
+ from .callback import CallbackClient
7
+
8
+ # R9 (round-9): AUTOFLOW_* keys that carry callback credentials / routing
9
+ # info (N23/N27 parity with executor-node). They are exposed as dedicated
10
+ # TaskContext fields and deliberately EXCLUDED from `params` — folding a
11
+ # one-shot HMAC token or a deployment address into user task parameters
12
+ # would pollute the parameter view and leak the credential into any code
13
+ # that dumps ctx.params.
14
+ _CALLBACK_ENV_KEYS = {
15
+ "AUTOFLOW_CALLBACK_TOKEN": "callback_token",
16
+ "AUTOFLOW_ADMIN_API_URL": "admin_api_url",
17
+ "AUTOFLOW_EXECUTOR_ADDRESS": "executor_address",
18
+ }
19
+
20
+
21
+ @dataclass
22
+ class TaskContext:
23
+ """Provides runtime metadata and helpers to a running task."""
24
+
25
+ task_id: str
26
+ execution_id: str
27
+ task_name: str
28
+ params: Dict[str, Any] = field(default_factory=dict)
29
+ env: Dict[str, str] = field(default_factory=dict)
30
+
31
+ # Callback credentials injected by the executor (N23/N27). All optional:
32
+ # older executors never inject them, in which case `ctx.callback` (and
33
+ # the `ctx.report_success` / `ctx.report_failure` shorthands) stay
34
+ # disabled and raise a descriptive error when used.
35
+ # N40 (round-10): repr=False — the dataclass __repr__ is a leak surface
36
+ # the same as to_dict (N27): a task's `print(ctx)` / f-string debug log
37
+ # would otherwise carry the one-shot HMAC token into the executor's
38
+ # captured stdout → admin DB (execution logs), where any JWT user who
39
+ # can read the execution could replay it within its TTL window.
40
+ callback_token: Optional[str] = field(default=None, repr=False)
41
+ admin_api_url: Optional[str] = field(default=None, repr=False)
42
+ executor_address: Optional[str] = field(default=None, repr=False)
43
+
44
+ # Runtime helpers — populated lazily
45
+ _logger: Optional[Any] = field(default=None, repr=False)
46
+ _callback_client: Optional[CallbackClient] = field(default=None, repr=False)
47
+
48
+ def get_param(self, key: str, default: Any = None) -> Any:
49
+ """Retrieve a task parameter by key."""
50
+ return self.params.get(key, default)
51
+
52
+ def get_env(self, key: str, default: str = "") -> str:
53
+ """Retrieve an environment variable (task env first, then OS env)."""
54
+ return self.env.get(key, os.environ.get(key, default))
55
+
56
+ @property
57
+ def log(self):
58
+ if self._logger is None:
59
+ from .logger import get_logger
60
+ object.__setattr__(self, "_logger", get_logger(self.task_name))
61
+ return self._logger
62
+
63
+ @property
64
+ def callback(self) -> CallbackClient:
65
+ """Per-execution Admin API callback client (N23/N27 parity).
66
+
67
+ Lazily built from the injected credentials; check
68
+ ``ctx.callback.enabled`` before reporting (older executors do not
69
+ inject the credentials). See :class:`autoflow_sdk.callback.CallbackClient`.
70
+ """
71
+ if self._callback_client is None:
72
+ object.__setattr__(
73
+ self,
74
+ "_callback_client",
75
+ CallbackClient(
76
+ admin_api_url=self.admin_api_url,
77
+ token=self.callback_token,
78
+ executor_address=self.executor_address,
79
+ execution_id=self.execution_id,
80
+ ),
81
+ )
82
+ return self._callback_client
83
+
84
+ def report_success(self, summary: Optional[str] = None, duration_ms: Optional[int] = None) -> Any:
85
+ """Report this execution's success to the Admin API.
86
+
87
+ Shorthand for ``ctx.callback.report_success(...)``; raises
88
+ ``CallbackDisabledError`` when callback credentials are absent.
89
+ """
90
+ return self.callback.report_success(summary=summary, duration_ms=duration_ms)
91
+
92
+ def report_failure(
93
+ self,
94
+ error: Any,
95
+ summary: Optional[str] = None,
96
+ duration_ms: Optional[int] = None,
97
+ failure_reason: str = "script_error",
98
+ ) -> Any:
99
+ """Report this execution's failure to the Admin API.
100
+
101
+ ``error`` is mapped to the callback item's ``errorMessage`` and
102
+ ``failure_reason`` (default ``script_error``) to its structured
103
+ ``failureReason``. Raises ``CallbackDisabledError`` when callback
104
+ credentials are absent.
105
+ """
106
+ return self.callback.report_failure(
107
+ error, summary=summary, duration_ms=duration_ms, failure_reason=failure_reason
108
+ )
109
+
110
+ @classmethod
111
+ def from_env(cls) -> "TaskContext":
112
+ """
113
+ Create TaskContext from environment variables injected by the executor.
114
+
115
+ Reads EXECUTION_ID, TASK_ID, TASK_NAME, and all AUTOFLOW_* vars as
116
+ params — except the callback credential keys
117
+ (``AUTOFLOW_CALLBACK_TOKEN``, ``AUTOFLOW_ADMIN_API_URL``,
118
+ ``AUTOFLOW_EXECUTOR_ADDRESS``), which are exposed as dedicated
119
+ fields and never leak into ``params``.
120
+
121
+ Usage::
122
+
123
+ from autoflow_sdk import TaskContext
124
+
125
+ ctx = TaskContext.from_env()
126
+ ctx.log.info(f"Task {ctx.task_id} started")
127
+ date = ctx.get_param("date")
128
+ """
129
+ params: dict[str, Any] = {}
130
+ callback_fields: dict[str, str] = {}
131
+ for k, v in os.environ.items():
132
+ if not k.startswith("AUTOFLOW_") or not v:
133
+ continue
134
+ if k in _CALLBACK_ENV_KEYS:
135
+ callback_fields[_CALLBACK_ENV_KEYS[k]] = v
136
+ continue
137
+ params[k[len("AUTOFLOW_"):].lower()] = v
138
+
139
+ return cls(
140
+ task_id=os.environ.get("TASK_ID", "unknown"),
141
+ execution_id=os.environ.get("EXECUTION_ID", "unknown"),
142
+ task_name=os.environ.get("TASK_NAME", "unknown"),
143
+ params=params,
144
+ **callback_fields,
145
+ )
146
+
147
+ def to_dict(self) -> Dict[str, Any]:
148
+ return {
149
+ "task_id": self.task_id,
150
+ "execution_id": self.execution_id,
151
+ "task_name": self.task_name,
152
+ "params": self.params,
153
+ }