create-windy 0.2.20 → 0.2.21

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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +84 -10
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/README.md +1 -0
  6. package/template/apps/agent-server/Dockerfile +20 -0
  7. package/template/apps/agent-server/README.md +46 -0
  8. package/template/apps/agent-server/pyproject.toml +44 -0
  9. package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
  11. package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
  18. package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
  23. package/template/apps/agent-server/tests/test_api.py +207 -0
  24. package/template/apps/agent-server/tests/test_config.py +56 -0
  25. package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
  26. package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
  27. package/template/apps/agent-server/uv.lock +925 -0
  28. package/template/apps/server/src/agent/execution-grants.ts +89 -0
  29. package/template/apps/server/src/agent/remote-runtime.ts +128 -0
  30. package/template/apps/server/src/agent/run-contracts.ts +43 -0
  31. package/template/apps/server/src/agent/run-facade.test.ts +257 -0
  32. package/template/apps/server/src/agent/run-facade.ts +190 -0
  33. package/template/apps/server/src/agent/run-routes.ts +222 -0
  34. package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
  35. package/template/apps/server/src/agent/tool-host.test.ts +242 -0
  36. package/template/apps/server/src/agent/tool-host.ts +153 -0
  37. package/template/apps/server/src/index.ts +12 -16
  38. package/template/apps/server/src/module-composition.test.ts +9 -0
  39. package/template/apps/server/src/module-composition.ts +13 -0
  40. package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
  41. package/template/apps/server/src/module-host/initialize.test.ts +69 -0
  42. package/template/apps/server/src/module-host/initialize.ts +50 -53
  43. package/template/apps/server/src/module-host.ts +1 -0
  44. package/template/apps/server/src/module-runtime-validation.ts +40 -0
  45. package/template/docker-compose.yml +25 -0
  46. package/template/docs/architecture/ai-runtime.md +744 -0
  47. package/template/docs/platform/agent-runtime.md +128 -0
  48. package/template/docs/platform/agent-tools.md +8 -1
  49. package/template/package.json +1 -0
  50. package/template/packages/config/index.test.ts +43 -0
  51. package/template/packages/config/index.ts +1 -0
  52. package/template/packages/config/package.json +2 -1
  53. package/template/packages/config/src/ai-openai-compatible.ts +131 -0
  54. package/template/packages/server-sdk/index.ts +11 -0
  55. package/template/packages/server-sdk/package.json +4 -2
  56. package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
  57. package/template/packages/server-sdk/src/module-host.ts +5 -0
  58. package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+ from typing import cast
7
+
8
+ import httpx
9
+ import pytest
10
+ from fastapi import FastAPI
11
+
12
+ from southwind_agent_server.app import create_app
13
+ from southwind_agent_server.deterministic import DeterministicEngineAdapter
14
+
15
+
16
+ @pytest.fixture
17
+ def app() -> FastAPI:
18
+ return create_app(DeterministicEngineAdapter(), heartbeat_seconds=0.01)
19
+
20
+
21
+ @pytest.fixture
22
+ async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
23
+ async with httpx.AsyncClient(
24
+ transport=httpx.ASGITransport(app=app),
25
+ base_url="http://agent.test",
26
+ ) as value:
27
+ yield value
28
+
29
+
30
+ async def start(
31
+ client: httpx.AsyncClient,
32
+ operation: str,
33
+ input_value: object,
34
+ key: str,
35
+ ) -> dict[str, object]:
36
+ response = await client.post(
37
+ "/api/agent/runs",
38
+ headers={"Idempotency-Key": key},
39
+ json={"operationKey": operation, "input": input_value},
40
+ )
41
+ assert response.status_code == 200
42
+ return cast(dict[str, object], response.json())
43
+
44
+
45
+ def events(response: httpx.Response) -> list[dict[str, object]]:
46
+ result: list[dict[str, object]] = []
47
+ for frame in response.text.split("\n\n"):
48
+ data = next(
49
+ (
50
+ line.removeprefix("data: ")
51
+ for line in frame.splitlines()
52
+ if line.startswith("data: ")
53
+ ),
54
+ None,
55
+ )
56
+ if data is not None:
57
+ result.append(json.loads(data))
58
+ return result
59
+
60
+
61
+ async def wait_for_terminal(
62
+ client: httpx.AsyncClient,
63
+ run_id: str,
64
+ ) -> list[dict[str, object]]:
65
+ response = await client.get(f"/api/agent/runs/{run_id}/events")
66
+ assert response.status_code == 200
67
+ return events(response)
68
+
69
+
70
+ async def test_result_and_canonical_sequence(client: httpx.AsyncClient) -> None:
71
+ created = await start(client, "test.echo", {"text": "你好"}, "echo-1")
72
+ run_id = str(created["runId"])
73
+ emitted = await wait_for_terminal(client, run_id)
74
+
75
+ assert [event["sequence"] for event in emitted] == [1, 2, 3, 4]
76
+ assert [event["type"] for event in emitted] == [
77
+ "run.started",
78
+ "output.delta",
79
+ "usage.reported",
80
+ "run.completed",
81
+ ]
82
+ result = await client.get(f"/api/agent/runs/{run_id}/result")
83
+ assert result.json() == {
84
+ "runId": run_id,
85
+ "status": "completed",
86
+ "output": {"text": "你好"},
87
+ }
88
+
89
+
90
+ async def test_replay_supports_header_and_after(client: httpx.AsyncClient) -> None:
91
+ created = await start(client, "test.echo", {"text": "replay"}, "replay-1")
92
+ run_id = str(created["runId"])
93
+ await wait_for_terminal(client, run_id)
94
+
95
+ from_header = await client.get(
96
+ f"/api/agent/runs/{run_id}/events",
97
+ headers={"Last-Event-ID": "2"},
98
+ )
99
+ from_query = await client.get(f"/api/agent/runs/{run_id}/events?after=3")
100
+ assert [event["sequence"] for event in events(from_header)] == [3, 4]
101
+ assert [event["sequence"] for event in events(from_query)] == [4]
102
+
103
+
104
+ async def test_idempotency_reuses_or_conflicts(client: httpx.AsyncClient) -> None:
105
+ first = await start(client, "test.echo", {"text": "same"}, "same-key")
106
+ second = await start(client, "test.echo", {"text": "same"}, "same-key")
107
+ assert second["runId"] == first["runId"]
108
+
109
+ conflict = await client.post(
110
+ "/api/agent/runs",
111
+ headers={"Idempotency-Key": "same-key"},
112
+ json={"operationKey": "test.echo", "input": {"text": "different"}},
113
+ )
114
+ assert conflict.status_code == 409
115
+ assert conflict.json()["code"] == "RUN_CONFLICT"
116
+
117
+
118
+ async def test_cancel_emits_one_terminal_and_heartbeat(
119
+ client: httpx.AsyncClient,
120
+ ) -> None:
121
+ created = await start(client, "test.wait", None, "cancel-1")
122
+ run_id = str(created["runId"])
123
+ stream = asyncio.create_task(client.get(f"/api/agent/runs/{run_id}/events"))
124
+ await asyncio.sleep(0.03)
125
+ cancelled = await client.post(
126
+ f"/api/agent/runs/{run_id}/cancel",
127
+ json={"reason": "user request"},
128
+ )
129
+ response = await stream
130
+ emitted = events(response)
131
+
132
+ assert cancelled.json()["status"] == "cancelled"
133
+ assert ": heartbeat" in response.text
134
+ assert [event["type"] for event in emitted].count("run.cancelled") == 1
135
+ assert emitted[-1]["type"] == "run.cancelled"
136
+ result = await client.get(f"/api/agent/runs/{run_id}/result")
137
+ assert result.status_code == 409
138
+ assert result.json()["code"] == "RUN_CANCELLED"
139
+
140
+
141
+ async def test_failure_never_leaks_upstream_exception(
142
+ client: httpx.AsyncClient,
143
+ ) -> None:
144
+ created = await start(client, "test.fail", None, "fail-1")
145
+ run_id = str(created["runId"])
146
+ emitted = await wait_for_terminal(client, run_id)
147
+ terminal = emitted[-1]
148
+
149
+ assert terminal["type"] == "run.failed"
150
+ serialized = json.dumps(terminal, ensure_ascii=False)
151
+ assert "upstream-secret-body" not in serialized
152
+ error = cast(dict[str, object], terminal["error"])
153
+ assert error["code"] == "RUNTIME_UNAVAILABLE"
154
+ result = await client.get(f"/api/agent/runs/{run_id}/result")
155
+ assert "upstream-secret-body" not in result.text
156
+
157
+
158
+ async def test_request_validation_uses_canonical_safe_error(
159
+ client: httpx.AsyncClient,
160
+ ) -> None:
161
+ response = await client.post(
162
+ "/api/agent/runs",
163
+ json={"operationKey": "", "input": None},
164
+ )
165
+
166
+ assert response.status_code == 400
167
+ assert response.json()["code"] == "INVALID_INPUT"
168
+ assert "detail" not in response.json()
169
+
170
+
171
+ async def test_internal_token_protects_run_api() -> None:
172
+ protected = create_app(
173
+ DeterministicEngineAdapter(),
174
+ internal_token="internal-secret",
175
+ )
176
+ async with httpx.AsyncClient(
177
+ transport=httpx.ASGITransport(app=protected),
178
+ base_url="http://agent.test",
179
+ ) as client:
180
+ denied = await client.post(
181
+ "/api/agent/runs",
182
+ headers={"Idempotency-Key": "protected-1"},
183
+ json={"operationKey": "test.echo", "input": None},
184
+ )
185
+ allowed = await client.post(
186
+ "/api/agent/runs",
187
+ headers={
188
+ "Idempotency-Key": "protected-1",
189
+ "Authorization": "Bearer internal-secret",
190
+ },
191
+ json={"operationKey": "test.echo", "input": None},
192
+ )
193
+
194
+ assert denied.status_code == 403
195
+ assert denied.json()["code"] == "FORBIDDEN"
196
+ assert allowed.status_code == 200
197
+
198
+
199
+ async def test_liveness_does_not_claim_provider_readiness(
200
+ client: httpx.AsyncClient,
201
+ ) -> None:
202
+ live = await client.get("/health/live")
203
+ ready = await client.get("/health/ready")
204
+
205
+ assert live.json() == {"status": "live"}
206
+ assert ready.status_code == 503
207
+ assert ready.json()["code"] == "RUNTIME_UNAVAILABLE"
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from southwind_agent_server.config import (
6
+ EnvironmentSecretResolver,
7
+ OpenAiCompatibleConfig,
8
+ normalize_base_url,
9
+ )
10
+
11
+
12
+ def test_server_config_uses_secret_ref_and_normalizes_endpoint() -> None:
13
+ environment = {
14
+ "QWEN_API_URL": "https://example.com/compatible-mode/v1/",
15
+ "QWEN_MODEL": "qwen-plus",
16
+ "QWEN_API_KEY": "server-secret",
17
+ }
18
+ config = OpenAiCompatibleConfig.from_environment(environment)
19
+ assert config is not None
20
+ assert config.base_url == "https://example.com/compatible-mode/v1"
21
+ assert str(config.api_key_ref) == "[SecretRef]"
22
+ resolver = EnvironmentSecretResolver(environment)
23
+ assert config.api_key_ref is not None
24
+ assert resolver.resolve(config.api_key_ref) == "server-secret"
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ "endpoint",
29
+ [
30
+ "http://public.example.com/v1",
31
+ "http://169.254.169.254/v1",
32
+ "https://user:pass@example.com/v1",
33
+ "https://example.com/v1?token=secret",
34
+ ],
35
+ )
36
+ def test_rejects_unsafe_endpoint(endpoint: str) -> None:
37
+ with pytest.raises(ValueError):
38
+ normalize_base_url(endpoint)
39
+
40
+
41
+ def test_allows_private_http_endpoint() -> None:
42
+ assert normalize_base_url("http://qwen.internal:9000/v1/") == (
43
+ "http://qwen.internal:9000/v1"
44
+ )
45
+ assert normalize_base_url("http://10.20.30.40:8000/v1") == (
46
+ "http://10.20.30.40:8000/v1"
47
+ )
48
+
49
+
50
+ def test_partial_deployment_fails_fast_without_leaking_value() -> None:
51
+ marker = "DO-NOT-LEAK"
52
+ with pytest.raises(ValueError) as captured:
53
+ OpenAiCompatibleConfig.from_environment(
54
+ {"QWEN_API_URL": marker, "QWEN_MODEL": ""}
55
+ )
56
+ assert marker not in str(captured.value)
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from southwind_agent_server.config import (
9
+ EnvironmentSecretResolver,
10
+ OpenAiCompatibleConfig,
11
+ SecretRef,
12
+ )
13
+ from southwind_agent_server.contracts import RuntimeLimits, RuntimeToolDefinition
14
+ from southwind_agent_server.engine import (
15
+ EngineRunRequest,
16
+ SafeEngineError,
17
+ ToolCompleted,
18
+ ToolRequested,
19
+ )
20
+ from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
21
+ from southwind_agent_server.openai_provider import OpenAiCompatibleProvider
22
+ from southwind_agent_server.tool_host import ToolHostClient
23
+
24
+
25
+ async def test_provider_stream_and_health_use_bounded_safe_protocol() -> None:
26
+ async def handler(request: httpx.Request) -> httpx.Response:
27
+ if request.url.path.endswith("/models"):
28
+ return httpx.Response(200, json={"data": []})
29
+ return sse_response(
30
+ [
31
+ {"choices": [{"delta": {"content": "你"}}]},
32
+ {
33
+ "choices": [{"delta": {"content": "好"}}],
34
+ "usage": {
35
+ "prompt_tokens": 2,
36
+ "completion_tokens": 2,
37
+ "total_tokens": 4,
38
+ },
39
+ },
40
+ ]
41
+ )
42
+
43
+ provider = create_provider(httpx.MockTransport(handler))
44
+ await provider.health()
45
+ turn = await provider.complete_turn(
46
+ [{"role": "user", "content": "你好"}],
47
+ (),
48
+ )
49
+ assert turn.text_deltas == ["你", "好"]
50
+ assert turn.usage == {
51
+ "source": "provider-reported",
52
+ "inputTokens": 2,
53
+ "outputTokens": 2,
54
+ "totalTokens": 4,
55
+ }
56
+
57
+
58
+ async def test_provider_error_never_leaks_body_or_secret() -> None:
59
+ marker = "upstream-secret-body"
60
+
61
+ async def handler(_request: httpx.Request) -> httpx.Response:
62
+ return httpx.Response(500, text=marker)
63
+
64
+ provider = create_provider(httpx.MockTransport(handler))
65
+ with pytest.raises(SafeEngineError) as captured:
66
+ await provider.complete_turn([], ())
67
+ assert captured.value.code == "PROVIDER_UNAVAILABLE"
68
+ assert marker not in str(captured.value)
69
+ assert "server-secret" not in str(captured.value)
70
+
71
+
72
+ async def test_engine_executes_only_allowed_tool_through_host() -> None:
73
+ provider_calls = 0
74
+ tool_requests: list[dict[str, object]] = []
75
+
76
+ async def provider_handler(request: httpx.Request) -> httpx.Response:
77
+ nonlocal provider_calls
78
+ provider_calls += 1
79
+ if provider_calls == 1:
80
+ return sse_response(
81
+ [
82
+ {
83
+ "choices": [
84
+ {
85
+ "delta": {
86
+ "tool_calls": [
87
+ {
88
+ "index": 0,
89
+ "id": "call_1",
90
+ "function": {
91
+ "name": "business.records.list",
92
+ "arguments": '{"page":2}',
93
+ },
94
+ }
95
+ ]
96
+ }
97
+ }
98
+ ]
99
+ }
100
+ ]
101
+ )
102
+ return sse_response([{"choices": [{"delta": {"content": "共有一条记录"}}]}])
103
+
104
+ async def tool_handler(request: httpx.Request) -> httpx.Response:
105
+ assert request.headers["authorization"] == "Bearer internal-token"
106
+ tool_requests.append(json.loads(request.content))
107
+ return httpx.Response(200, json={"value": {"total": 1}})
108
+
109
+ engine = OpenAiCompatibleEngineAdapter(
110
+ create_provider(httpx.MockTransport(provider_handler)),
111
+ ToolHostClient(
112
+ "internal-token",
113
+ transport=httpx.MockTransport(tool_handler),
114
+ ),
115
+ )
116
+ signals = [
117
+ signal
118
+ async for signal in engine.execute(runtime_request("business.records.list"))
119
+ ]
120
+ assert any(isinstance(signal, ToolRequested) for signal in signals)
121
+ assert any(isinstance(signal, ToolCompleted) for signal in signals)
122
+ assert tool_requests[0] == {
123
+ "executionGrant": "grant_1",
124
+ "runId": "run_1",
125
+ "toolCallId": "call_1",
126
+ "toolKey": "business.records.list",
127
+ "arguments": {"page": 2},
128
+ }
129
+ assert signals[-1].output == {"text": "共有一条记录"} # type: ignore[union-attr]
130
+
131
+
132
+ async def test_engine_denies_unknown_model_tool_before_host_callback() -> None:
133
+ tool_called = False
134
+
135
+ async def provider_handler(_request: httpx.Request) -> httpx.Response:
136
+ return sse_response(
137
+ [
138
+ {
139
+ "choices": [
140
+ {
141
+ "delta": {
142
+ "tool_calls": [
143
+ {
144
+ "index": 0,
145
+ "id": "call_bad",
146
+ "function": {
147
+ "name": "business.admin.delete",
148
+ "arguments": "{}",
149
+ },
150
+ }
151
+ ]
152
+ }
153
+ }
154
+ ]
155
+ }
156
+ ]
157
+ )
158
+
159
+ async def tool_handler(_request: httpx.Request) -> httpx.Response:
160
+ nonlocal tool_called
161
+ tool_called = True
162
+ return httpx.Response(200, json={"value": None})
163
+
164
+ engine = OpenAiCompatibleEngineAdapter(
165
+ create_provider(httpx.MockTransport(provider_handler)),
166
+ ToolHostClient(
167
+ "internal-token",
168
+ transport=httpx.MockTransport(tool_handler),
169
+ ),
170
+ )
171
+ with pytest.raises(SafeEngineError) as captured:
172
+ async for _signal in engine.execute(runtime_request("business.records.list")):
173
+ pass
174
+ assert captured.value.code == "TOOL_DENIED"
175
+ assert tool_called is False
176
+
177
+
178
+ def create_provider(
179
+ transport: httpx.AsyncBaseTransport,
180
+ ) -> OpenAiCompatibleProvider:
181
+ environment = {"QWEN_API_KEY": "server-secret"}
182
+ return OpenAiCompatibleProvider(
183
+ OpenAiCompatibleConfig(
184
+ base_url="https://provider.test/v1",
185
+ model="qwen-compatible",
186
+ api_key_ref=SecretRef("env", "QWEN_API_KEY"),
187
+ ),
188
+ EnvironmentSecretResolver(environment),
189
+ transport=transport,
190
+ )
191
+
192
+
193
+ def runtime_request(tool_key: str) -> EngineRunRequest:
194
+ return EngineRunRequest(
195
+ run_id="run_1",
196
+ operation_key="business.answer",
197
+ input={"question": "记录"},
198
+ instructions="回答业务问题。",
199
+ user_prompt="有多少记录?",
200
+ allowed_tools=(
201
+ RuntimeToolDefinition(
202
+ key=tool_key,
203
+ label="查询记录",
204
+ description="查询当前数据范围内的记录。",
205
+ ),
206
+ ),
207
+ limits=RuntimeLimits(
208
+ max_duration_seconds=60,
209
+ max_output_bytes=4096,
210
+ ),
211
+ execution_grant="grant_1",
212
+ tool_host_url="http://server:9747",
213
+ )
214
+
215
+
216
+ def sse_response(chunks: list[object]) -> httpx.Response:
217
+ body = "".join(
218
+ f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" for chunk in chunks
219
+ )
220
+ return httpx.Response(
221
+ 200,
222
+ headers={"content-type": "text/event-stream"},
223
+ content=f"{body}data: [DONE]\n\n",
224
+ )
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ import pytest
7
+
8
+ from southwind_agent_server.sqlite_store import (
9
+ RunStoreCapacityError,
10
+ SqliteRunStore,
11
+ )
12
+ from southwind_agent_server.store import RunRecord
13
+
14
+
15
+ async def test_terminal_run_and_cursor_survive_store_rebuild(
16
+ tmp_path: Path,
17
+ ) -> None:
18
+ path = str(tmp_path / "runs.sqlite3")
19
+ first = SqliteRunStore(path)
20
+ record = fixture("run_1", "key_1")
21
+ await first.create_or_get(record)
22
+ await first.append("run_1", "run.started")
23
+ await first.append("run_1", "output.delta", {"text": "你好"})
24
+ await first.complete("run_1", {"text": "你好"})
25
+ first.close()
26
+
27
+ restored = SqliteRunStore(path)
28
+ current = await restored.get("run_1")
29
+ events, terminal = await restored.events_after("run_1", 1, 0.01)
30
+ restored.close()
31
+
32
+ assert current is not None
33
+ assert current.status == "completed"
34
+ assert current.output == {"text": "你好"}
35
+ assert [event["sequence"] for event in events] == [2, 3]
36
+ assert terminal is True
37
+
38
+
39
+ async def test_incomplete_run_recovers_as_one_safe_terminal(
40
+ tmp_path: Path,
41
+ ) -> None:
42
+ path = str(tmp_path / "runs.sqlite3")
43
+ first = SqliteRunStore(path)
44
+ await first.create_or_get(fixture("run_2", "key_2"))
45
+ await first.append("run_2", "run.started")
46
+ first.close()
47
+
48
+ restored = SqliteRunStore(path)
49
+ current = await restored.get("run_2")
50
+ restored.close()
51
+
52
+ assert current is not None
53
+ assert current.status == "failed"
54
+ assert [event["type"] for event in current.events] == [
55
+ "run.started",
56
+ "run.failed",
57
+ ]
58
+ error = cast(dict[str, object], current.events[-1]["error"])
59
+ assert error["code"] == "RUNTIME_UNAVAILABLE"
60
+ assert error["retryable"] is True
61
+ assert "secret" not in str(current.events)
62
+
63
+
64
+ async def test_event_and_run_capacity_are_bounded(tmp_path: Path) -> None:
65
+ store = SqliteRunStore(
66
+ str(tmp_path / "bounded.sqlite3"),
67
+ max_runs=1,
68
+ max_events_per_run=3,
69
+ )
70
+ await store.create_or_get(fixture("run_3", "key_3"))
71
+ await store.append("run_3", "run.started")
72
+ await store.append("run_3", "output.delta", {"text": "one"})
73
+ with pytest.raises(RunStoreCapacityError):
74
+ await store.append("run_3", "output.delta", {"text": "two"})
75
+
76
+ current = await store.get("run_3")
77
+ assert current is not None
78
+ assert current.status == "failed"
79
+ assert current.events[-1]["type"] == "run.failed"
80
+
81
+ with pytest.raises(RunStoreCapacityError):
82
+ await store.create_or_get(fixture("run_4", "key_4"))
83
+ store.close()
84
+
85
+
86
+ def fixture(run_id: str, idempotency_key: str) -> RunRecord:
87
+ return RunRecord(
88
+ run_id=run_id,
89
+ operation_key="test.echo",
90
+ input={"text": "hello"},
91
+ idempotency_key=idempotency_key,
92
+ fingerprint=f"fingerprint-{run_id}",
93
+ )