Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,31 @@ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b"
| `file_listing` | `get_dir_files_as_list`: action files under a directory | — |
| `socket_server`, `socket_auth` | `start_action_socket_server` and the request handlers (plain, secret header, JSON-envelope token) | executor, payload check, errors answered, framing, TLS, reply templates, secret |
| `builtins_policy` | `SAFE_BUILTINS`: the builtins an action list may call | — |
| `request_record`, `request_context` | Versioned request-result validation, JSON serialization/schema and isolated `RunContext` storage | source, phase, engine, worker and run identities |

`__all__` in `je_action_core/__init__.py` is the supported import surface.

## Request results

HTTP `request_method`: uppercase ASCII token; schema and Python validation enforce the same rule.

Run identities are validated when the context is constructed. Retries compare JSON content with object keys sorted, distinguishing booleans from numbers. Assertion messages must be strings when present, and failed assertions require a message. Integer values beyond the portable JSON conversion limit and unrepresentable measurement values raise field-located contract errors.

Request results are separate from executor action records. `RequestRecord` v1 gives functional,
load and synthetic runs the same method/URL, numeric status, nullable measurements, structured error,
assertions and run/worker/step identities. `validate_request_record` returns a detached JSON-safe result;
`serialize_request_record` exports it and `request_record_schema` describes the contract.
Bytes become base64 objects, datetimes become epoch seconds and timedeltas become milliseconds.
Unknown versions, invalid fields, non-finite measurements and opaque objects raise `RequestRecordError`.

`RunContext(source="loaddensity", phase="load", engine="asyncio")` owns one run's records.
Use `capture(fields)` for a new result, `append(record)` for a validated retry, `snapshot()` for a detached
copy and `to_json()` for an array export. Identical record IDs deduplicate; conflicting retries and foreign
run identities raise an error. `use_run_context(context)` activates it for a thread/task scope and restores
the previous context on exit; `get_run_context()` returns the active context or `None`.
Storage retains the complete run; long-lived monitors should create a new context per iteration and persist results.
The contract records the runner's judgement and does not decide whether a status code is successful.

## Package gate

`PackageManager.add_package_to_executor` imports a package and registers its members as commands. An action list
Expand Down
22 changes: 22 additions & 0 deletions README/README_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,31 @@ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b"
| `file_listing` | `get_dir_files_as_list`:找出目录下的 action 文件 | — |
| `socket_server`、`socket_auth` | `start_action_socket_server` 与请求处理器(无认证、密钥标头、JSON 信封令牌) | 执行器、内容检查、要回复的错误、分帧、TLS、回复模板、密钥 |
| `builtins_policy` | `SAFE_BUILTINS`:action 列表可以调用的内置函数 | — |
| `request_record`、`request_context` | 版本化 request 结果验证、JSON 序列化/schema 与隔离的 `RunContext` 存储 | 来源、阶段、引擎、worker 与 run 标识 |

`je_action_core/__init__.py` 里的 `__all__` 是正式支持的导入接口。

## Request 结果

HTTP `request_method`: uppercase ASCII token; schema and Python validation enforce the same rule.

Run identity 在 context 创建时即验证。重送以排序对象键后的 JSON 内容比较,区分布尔值与数字。
断言信息存在时必须是字符串,失败断言必须带信息。超过可移植 JSON 转换限制的整数及无法表示的测量值
会产生包含字段位置的契约错误。

Request 结果与 executor 的 action 记录分开。`RequestRecord` v1 让功能、负载与合成监控使用
相同的方法/URL、数字状态码、可为 null 的测量值、结构化错误、assertion 与 run/worker/step 标识。
`validate_request_record` 返回隔离且可序列化的结果;`serialize_request_record` 输出 JSON,
`request_record_schema` 描述契约。bytes 转为 base64 对象、datetime 转为 epoch 秒、timedelta 转为毫秒。
未知版本、非法字段、非有限测量值与不支持的对象会引发 `RequestRecordError`。

`RunContext(source="loaddensity", phase="load", engine="asyncio")` 保存一次运行的记录。
`capture(fields)` 添加结果,`append(record)` 接收已验证的重发,`snapshot()` 获取隔离副本,
`to_json()` 输出数组。同 ID 的相同结果不重复计数;冲突结果与其他 run 的标识会引发错误。
`use_run_context(context)` 在线程/task 范围启用它,退出时恢复原 context;
`get_run_context()` 返回当前 context 或 `None`。存储保留整次运行,长期监控应每轮创建新 context 并持久化结果。
契约保存 runner 的判断,不自行决定状态码是否成功。

## 包闸门

`PackageManager.add_package_to_executor` 会导入一个包,并把它的成员注册成命令。所以只要 action 列表写得出
Expand Down
22 changes: 22 additions & 0 deletions README/README_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,31 @@ executor.execute_action({"my_tool": [["MY_add", [1, 2]], ["MY_add", {"a": 3, "b"
| `file_listing` | `get_dir_files_as_list`:找出目錄底下的 action 檔 | — |
| `socket_server`、`socket_auth` | `start_action_socket_server` 與請求處理器(無驗證、祕密標頭、JSON 信封權杖) | 執行器、內容檢查、要回覆的錯誤、分框、TLS、回覆範本、祕密 |
| `builtins_policy` | `SAFE_BUILTINS`:action 清單可以呼叫的內建函式 | — |
| `request_record`、`request_context` | 版本化 request 結果驗證、JSON 序列化/schema 與隔離的 `RunContext` 儲存 | 來源、階段、引擎、worker 與 run 識別 |

`je_action_core/__init__.py` 裡的 `__all__` 是正式支援的匯入介面。

## Request 結果

HTTP `request_method`: uppercase ASCII token; schema and Python validation enforce the same rule.

Run identity 在 context 建立時即驗證。重送以排序物件鍵後的 JSON 內容比較,區分布林值與數字。
斷言訊息存在時必須是字串,失敗斷言必須帶訊息。超過可攜式 JSON 轉換限制的整數及無法表示的測量值
會產生包含欄位位置的契約錯誤。

Request 結果與 executor 的 action 紀錄分開。`RequestRecord` v1 讓功能、負載與合成監控使用
相同的方法/URL、數字狀態碼、可為 null 的量測值、結構化錯誤、assertion 與 run/worker/step 識別。
`validate_request_record` 回傳隔離且可序列化的結果;`serialize_request_record` 輸出 JSON,
`request_record_schema` 描述契約。bytes 轉成 base64 物件、datetime 轉成 epoch 秒、timedelta 轉成毫秒。
未知版本、非法欄位、非有限量測值與不支援的物件會引發 `RequestRecordError`。

`RunContext(source="loaddensity", phase="load", engine="asyncio")` 保存一次執行的紀錄。
`capture(fields)` 新增結果,`append(record)` 接收已驗證的重送,`snapshot()` 取得隔離副本,
`to_json()` 輸出陣列。同 ID 的相同結果不重複計數;衝突結果與其他 run 的識別會引發錯誤。
`use_run_context(context)` 在執行緒/task 範圍啟用它,離開時還原原 context;
`get_run_context()` 回傳目前 context 或 `None`。儲存保留整次執行,長期監控應每輪建立新 context 並持久化結果。
契約保存 runner 的判定,不自行決定狀態碼是否成功。

## 套件閘門

`PackageManager.add_package_to_executor` 會匯入一個套件,並把它的成員註冊成命令。所以只要 action 清單寫得出
Expand Down
9 changes: 9 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ Nothing in the package imports a project; the projects import it.

## 6. Cross-project boundaries

RunContext rejects invalid identities during construction and compares sorted JSON for retries; booleans and numbers are distinct. Assertion schema constraints and uppercase ASCII HTTP method tokens match the Python validator. Measurements must be representable as finite numbers; integers must fit the portable JSON conversion limit.

`request_record.py` owns the separate RequestRecord v1 contract (TypedDict, validator, JSON serializer
and JSON Schema); `request_context.py` owns explicit `RunContext` storage and ContextVar scopes.
They use only the standard library and do not alter executor action records. Framework adapters remain
in the consumers. Contexts retain complete runs; consumers must rotate/persist long-lived monitoring runs.
New consumers are developed against isolated checkouts until the core release exposes these APIs;
their existing package imports must remain usable with the previously published dependency floor.

**Used by.** Each project that moves adds a row here; the same round updates that project's own
`architecture.md` §6. The projects install it from PyPI (`je_action_core>=0.0.1` to `>=0.0.3`, each the release that
has what it uses); a change they need is released first and their minimum version raised in the same round.
Expand Down
19 changes: 19 additions & 0 deletions docs/updates/2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,22 @@
- **Docs**: `architecture.md` §2's `.github/requirements/` and `test/` rows. `CLAUDE.md` and the three READMEs do not describe how the job builds, so none changed.
- **Files**: `.github/requirements/publish.in`, `.github/requirements/publish.txt` (generated), `.github/workflows/publish.yml`, `test/test_workflow_actions.py`, `architecture.md`.
- **Open items**: none. `publish.yml` takes effect once `dev` is merged into `main`.

## U-20261002-01 · 2026-10-02 · Add versioned request records and isolated run contexts · #done #records

- **What**: added RequestRecord v1 validation, JSON-safe serialization/schema and explicit RunContext storage. Contexts isolate runs and task scopes; retry IDs deduplicate and conflicting/foreign records are refused. Legacy action-executor records are untouched and dependencies remain empty.
- **Validation**: 172 passed, including 48 new record/context tests; ruff E/F/W/C90/I passed for the new modules. Baseline was 124 passed.
- **Files**: je_action_core/request_record.py, request_context.py, public exports, new tests, three READMEs and architecture.md.
- **Release**: feature branch only; consumer features stay opt-in until the core API is published. No version number was edited.

## U-20261002-02 · 2026-10-02 · Validate run identities and JSON retry semantics · #done #records

Run contexts reject invalid identities before requests run. Retry comparison distinguishes boolean and numeric JSON values. Assertion messages and passed-outcome constraints now agree between schema and validator. Extreme integer inputs produce field-located errors. Regressions cover all review findings, including optional Draft 2020-12 schema validation.

## U-20261002-03 · 2026-10-02 · Align HTTP method schema validation · #done #records

The exported schema now enforces the same uppercase ASCII HTTP method-token rule as Python validation. Paired tests reject lowercase, whitespace and non-ASCII method names.

## U-20261002-04 · 2026-10-02 · Reject trailing newlines in HTTP method schema · #done #records

The JSON Schema HTTP method pattern now uses a strict end guard, so a trailing newline cannot bypass the token constraint. A paired schema/validator regression covers this regular-expression edge case.
4 changes: 4 additions & 0 deletions docs/updates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here

| ID | Date | Title | Tags | Batch |
|---|---|---|---|---|
| U-20261002-04 | 2026-10-02 | Reject trailing newlines in HTTP method schema | #done #records | [2026-10](2026-10.md) |
| U-20261002-03 | 2026-10-02 | Align HTTP method schema validation | #done #records | [2026-10](2026-10.md) |
| U-20261002-02 | 2026-10-02 | Validate run identities and JSON retry semantics | #done #records | [2026-10](2026-10.md) |
| U-20261002-01 | 2026-10-02 | Add versioned request records and isolated run contexts | #done #records | [2026-10](2026-10.md) |
| U-20261001-12 | 2026-10-01 | The publish job builds with the locked setuptools instead of downloading the newest | #change #ci #security #X-13 | [2026-10](2026-10.md) |
| U-20261001-11 | 2026-10-01 | Publish job installs hash-locked build tooling | #done #ci #security #X-13 | [2026-10](2026-10.md) |
| U-20261001-10 | 2026-10-01 | The sdist carries no tests | #done #packaging #X-13 | [2026-10](2026-10.md) |
Expand Down
12 changes: 12 additions & 0 deletions je_action_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@
)
from je_action_core.registry import Command, CommandPolicy, CommandRegistry
from je_action_core.reporting import ExecutionReporter, LoggingReporter, PrintReporter
from je_action_core.request_context import RunContext, get_run_context, use_run_context
from je_action_core.request_record import (
RecordError,
RequestRecord,
RequestRecordError,
request_record_schema,
serialize_request_record,
validate_request_record,
)
from je_action_core.socket_auth import EnvelopeTokenRequestHandler, SecretHeaderRequestHandler, secret_matches
from je_action_core.socket_server import (
END_MARKER,
Expand Down Expand Up @@ -88,4 +97,7 @@
"FailureStage", "Framing", "OversizePolicy", "ReplyMessages", "SocketServerSettings", "server_tls_context",
"start_action_socket_server",
"EnvelopeTokenRequestHandler", "SecretHeaderRequestHandler", "secret_matches",
"RecordError", "RequestRecord", "RequestRecordError", "request_record_schema",
"serialize_request_record", "validate_request_record",
"RunContext", "get_run_context", "use_run_context",
]
82 changes: 82 additions & 0 deletions je_action_core/request_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Explicit run-scoped request storage shared by synchronous and asynchronous runners."""

from __future__ import annotations

import copy
import json
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from threading import RLock
from uuid import uuid4

from je_action_core.request_record import (
RequestRecord,
RequestRecordError,
_validate_run_identity,
validate_request_record,
)


class RunContext:
"""Own request identities and records for one run; snapshots never expose mutable storage."""

def __init__(self, source: str, phase: str, engine: str, worker_id: str | None = None,
run_id: str | None = None) -> None:
self.run_id = str(uuid4()) if run_id is None else run_id
self._identity = {"run_id": self.run_id, "source": source, "phase": phase,
"engine": engine, "worker_id": worker_id}
_validate_run_identity(self._identity)
self._records: dict[str, RequestRecord] = {}
self._lock = RLock()

def capture(self, fields: Mapping[str, object]) -> RequestRecord:
"""Build and append a measured result with this run's identity and a unique record ID."""
record = {"schema_version": 1, "record_id": str(uuid4()), "scenario_id": None, "step_id": None,
"assertions": [], "extensions": {}, **self._identity, **fields}
validated = validate_request_record(record)
self.append(validated)
return validated

def append(self, record: Mapping[str, object]) -> bool:
"""Append once, returning False for identical retries and rejecting conflicts or foreign runs."""
validated = validate_request_record(record)
for field, expected in self._identity.items():
if validated[field] != expected:
raise RequestRecordError(f"{field}: does not belong to this run context")
identifier = validated["record_id"]
with self._lock:
previous = self._records.get(identifier)
if previous is not None:
if json.dumps(previous, sort_keys=True) != json.dumps(validated, sort_keys=True):
raise RequestRecordError("record_id: conflicting retry of an existing record")
return False
self._records[identifier] = validated
return True

def snapshot(self) -> list[RequestRecord]:
"""Return a detached, insertion-ordered copy of all records in this run."""
with self._lock:
return copy.deepcopy(list(self._records.values()))

def to_json(self) -> str:
"""Export this run's canonical records as a JSON array."""
return json.dumps(self.snapshot(), ensure_ascii=False, allow_nan=False)


_active_context: ContextVar[RunContext | None] = ContextVar("request_run_context", default=None)


def get_run_context() -> RunContext | None:
"""Return the request context active in this thread/task, if explicitly selected."""
return _active_context.get()


@contextmanager
def use_run_context(context: RunContext) -> Iterator[RunContext]:
"""Activate a context for a scope, restoring the prior scope even when the runner fails."""
token = _active_context.set(context)
try:
yield context
finally:
_active_context.reset(token)
Loading
Loading