create-windy 0.4.1 → 0.5.0

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 (174) hide show
  1. package/dist/cli.js +258 -121
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/adk_web_agents/canonical_debug/agent.py +29 -9
  5. package/template/apps/agent-server/adk_web_agents/services.py +25 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +40 -11
  7. package/template/apps/agent-server/src/southwind_agent_server/adk_debug_sessions.py +325 -0
  8. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +70 -6
  9. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +1 -1
  10. package/template/apps/agent-server/src/southwind_agent_server/app.py +16 -4
  11. package/template/apps/agent-server/src/southwind_agent_server/approval_resolution.py +30 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +9 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +2 -1
  14. package/template/apps/agent-server/src/southwind_agent_server/engine.py +3 -1
  15. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +95 -130
  16. package/template/apps/agent-server/src/southwind_agent_server/openai_engine_validation.py +133 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +89 -20
  18. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_insert.py +34 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_queries.py +234 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_replay.py +44 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_status.py +83 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_store.py +331 -0
  23. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_summary.py +304 -0
  24. package/template/apps/agent-server/src/southwind_agent_server/report_lifecycle.py +217 -0
  25. package/template/apps/agent-server/src/southwind_agent_server/report_metrics.py +149 -0
  26. package/template/apps/agent-server/src/southwind_agent_server/report_presenter.py +280 -0
  27. package/template/apps/agent-server/src/southwind_agent_server/report_routes.py +126 -0
  28. package/template/apps/agent-server/src/southwind_agent_server/report_store.py +344 -0
  29. package/template/apps/agent-server/src/southwind_agent_server/report_types.py +111 -0
  30. package/template/apps/agent-server/src/southwind_agent_server/runtime_factory.py +31 -2
  31. package/template/apps/agent-server/src/southwind_agent_server/service.py +56 -51
  32. package/template/apps/agent-server/src/southwind_agent_server/signal_events.py +18 -1
  33. package/template/apps/agent-server/tests/test_adk_adapter.py +7 -9
  34. package/template/apps/agent-server/tests/test_adk_debug_sessions.py +215 -0
  35. package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +48 -1
  36. package/template/apps/agent-server/tests/test_openai_provider.py +58 -6
  37. package/template/apps/agent-server/tests/test_postgres_report_status.py +59 -0
  38. package/template/apps/agent-server/tests/test_postgres_report_summary.py +298 -0
  39. package/template/apps/agent-server/tests/test_provider_attempt_reporting.py +152 -0
  40. package/template/apps/agent-server/tests/test_report_attempt_lifecycle.py +126 -0
  41. package/template/apps/agent-server/tests/test_report_resilience.py +181 -0
  42. package/template/apps/agent-server/tests/test_report_store.py +286 -0
  43. package/template/apps/agent-server/tests/test_report_summary_parity.py +115 -0
  44. package/template/apps/agent-server/tests/test_report_terminal_sync.py +177 -0
  45. package/template/apps/server/src/agent/debug-session-facade-provider-failure.test.ts +269 -0
  46. package/template/apps/server/src/agent/debug-session-facade.test.ts +335 -0
  47. package/template/apps/server/src/agent/debug-session-facade.ts +302 -0
  48. package/template/apps/server/src/agent/debug-session-id.test.ts +37 -0
  49. package/template/apps/server/src/agent/debug-session-id.ts +37 -0
  50. package/template/apps/server/src/agent/debug-session-provider-reader.ts +54 -0
  51. package/template/apps/server/src/agent/debug-session-routes.test.ts +64 -0
  52. package/template/apps/server/src/agent/debug-session-routes.ts +73 -0
  53. package/template/apps/server/src/agent/debug-session-validation.ts +106 -0
  54. package/template/apps/server/src/agent/drizzle-run-binding-repository.ts +2 -0
  55. package/template/apps/server/src/agent/provider-input-governance.test.ts +18 -0
  56. package/template/apps/server/src/agent/provider-input-governance.ts +37 -1
  57. package/template/apps/server/src/agent/remote-runtime.test.ts +7 -0
  58. package/template/apps/server/src/agent/remote-runtime.ts +1 -0
  59. package/template/apps/server/src/agent/run-authorization.test.ts +98 -0
  60. package/template/apps/server/src/agent/run-authorization.ts +32 -1
  61. package/template/apps/server/src/agent/run-binding-repository.ts +1 -0
  62. package/template/apps/server/src/agent/run-contracts.ts +5 -0
  63. package/template/apps/server/src/agent/run-facade-adk-debug.test.ts +39 -0
  64. package/template/apps/server/src/agent/run-facade.test.ts +78 -0
  65. package/template/apps/server/src/agent/run-facade.ts +33 -3
  66. package/template/apps/server/src/agent/run-input-commitment.ts +50 -0
  67. package/template/apps/server/src/agent/run-input-verification.ts +41 -0
  68. package/template/apps/server/src/agent/runtime-bootstrap-report.test.ts +136 -0
  69. package/template/apps/server/src/agent/runtime-bootstrap.ts +66 -1
  70. package/template/apps/server/src/agent/tool-host.test.ts +1 -0
  71. package/template/apps/server/src/agent/unavailable-routes.ts +4 -0
  72. package/template/apps/server/src/ai-runtime-report/actor-directory.ts +94 -0
  73. package/template/apps/server/src/ai-runtime-report/actor-handle.test.ts +27 -0
  74. package/template/apps/server/src/ai-runtime-report/actor-handle.ts +81 -0
  75. package/template/apps/server/src/ai-runtime-report/contracts.ts +91 -0
  76. package/template/apps/server/src/ai-runtime-report/remote-parser.ts +212 -0
  77. package/template/apps/server/src/ai-runtime-report/remote-port.ts +138 -0
  78. package/template/apps/server/src/ai-runtime-report/routes.ts +172 -0
  79. package/template/apps/server/src/ai-runtime-report/scope.ts +63 -0
  80. package/template/apps/server/src/ai-runtime-report/service.test.ts +271 -0
  81. package/template/apps/server/src/ai-runtime-report/service.ts +206 -0
  82. package/template/apps/server/src/license/runtime-audit-reliability.test.ts +2 -0
  83. package/template/apps/server/src/license/runtime-service.test.ts +6 -0
  84. package/template/apps/server/src/module-host/ai-operation-port.test.ts +63 -0
  85. package/template/apps/server/src/module-host/ai-operation-port.ts +30 -5
  86. package/template/apps/server/src/module-host/debug-session-provider-registration.test.ts +162 -0
  87. package/template/apps/server/src/module-host/debug-session-provider-registration.ts +84 -0
  88. package/template/apps/server/src/module-host/initialize-contracts.ts +7 -0
  89. package/template/apps/server/src/module-host/initialize.ts +6 -197
  90. package/template/apps/server/src/module-host/request-context.ts +1 -0
  91. package/template/apps/server/src/module-host/scoped-host.ts +214 -0
  92. package/template/apps/server/src/module-host.ts +1 -0
  93. package/template/apps/server/src/runtime-adk-debug.test.ts +25 -1
  94. package/template/apps/server/src/runtime-adk-debug.ts +8 -2
  95. package/template/apps/server/src/system/operations.test.ts +54 -0
  96. package/template/apps/server/src/system/operations.ts +23 -1
  97. package/template/apps/server/src/system/seed.test.ts +4 -0
  98. package/template/apps/server/src/system/system-resource-monitoring.test.ts +82 -0
  99. package/template/apps/server/src/system/system-resource-monitoring.ts +300 -0
  100. package/template/apps/web/src/layout/AppSidebar.vue +6 -1
  101. package/template/apps/web/src/layout/AppSidebar.webtest.ts +2 -0
  102. package/template/apps/web/src/pages/auth/LoginPage.vue +6 -1
  103. package/template/apps/web/src/pages/settings/BrandingSettingsSection.vue +20 -3
  104. package/template/apps/web/src/pages/settings/BrandingSettingsSection.webtest.ts +80 -0
  105. package/template/apps/web/src/pages/system/AiRuntimePage.vue +129 -0
  106. package/template/apps/web/src/pages/system/SystemOperationsPage.vue +2 -1
  107. package/template/apps/web/src/pages/system/ai-runtime-presenter.ts +60 -0
  108. package/template/apps/web/src/pages/system/ai-runtime-presenter.webtest.ts +48 -0
  109. package/template/apps/web/src/pages/system/audit-time-range.ts +34 -2
  110. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +4 -3
  111. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +26 -1
  112. package/template/apps/web/src/pages/system/components/OperationGpuMonitor.vue +98 -0
  113. package/template/apps/web/src/pages/system/components/OperationGpuMonitor.webtest.ts +61 -0
  114. package/template/apps/web/src/pages/system/components/OperationServiceMonitor.vue +53 -0
  115. package/template/apps/web/src/pages/system/components/OperationServiceMonitor.webtest.ts +20 -1
  116. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +14 -1
  117. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeActivityChart.vue +85 -0
  118. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeCharts.webtest.ts +82 -0
  119. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.vue +98 -0
  120. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.webtest.ts +27 -0
  121. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunDetailDialog.vue +168 -0
  122. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunTable.vue +130 -0
  123. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeSummaryCards.vue +82 -0
  124. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeSummaryCards.webtest.ts +102 -0
  125. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeTokenByModelChart.vue +101 -0
  126. package/template/apps/web/src/pages/system/composables/useAiRuntimeReport.ts +185 -0
  127. package/template/apps/web/src/pages/system/composables/useAiRuntimeReport.webtest.ts +158 -0
  128. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +21 -7
  129. package/template/apps/web/src/router/error-navigation.ts +22 -0
  130. package/template/apps/web/src/router/index.ts +12 -23
  131. package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
  132. package/template/apps/web/src/services/ai-runtime-api.ts +164 -0
  133. package/template/apps/web/src/services/ai-runtime-api.webtest.ts +111 -0
  134. package/template/apps/web/src/services/operations-api.ts +28 -0
  135. package/template/docker-compose.agent-debug.yml +1 -1
  136. package/template/docs/architecture/ai-runtime.md +2 -1
  137. package/template/docs/architecture/module-host.md +3 -0
  138. package/template/docs/platform/agent-run-attestation.md +65 -0
  139. package/template/docs/platform/agent-runtime-observability.md +135 -0
  140. package/template/docs/platform/agent-runtime.md +47 -3
  141. package/template/docs/platform/operations-overview.md +13 -0
  142. package/template/docs/platform/platform-authentication.md +4 -3
  143. package/template/packages/config/package.json +1 -1
  144. package/template/packages/database/drizzle/0045_tiny_cassandra_nova.sql +65 -0
  145. package/template/packages/database/drizzle/0046_tranquil_living_mummy.sql +6 -0
  146. package/template/packages/database/drizzle/0047_oval_luke_cage.sql +18 -0
  147. package/template/packages/database/drizzle/meta/0045_snapshot.json +8814 -0
  148. package/template/packages/database/drizzle/meta/0046_snapshot.json +8820 -0
  149. package/template/packages/database/drizzle/meta/0047_snapshot.json +8835 -0
  150. package/template/packages/database/drizzle/meta/_journal.json +21 -0
  151. package/template/packages/database/package.json +1 -1
  152. package/template/packages/database/src/agent-runtime-schema.test.ts +54 -0
  153. package/template/packages/database/src/schema/agent-runtime.ts +114 -0
  154. package/template/packages/jobs/package.json +1 -1
  155. package/template/packages/modules/package.json +1 -1
  156. package/template/packages/modules/src/system-admin-routes.ts +10 -0
  157. package/template/packages/modules/src/system-admin-ui-catalog.ts +1 -1
  158. package/template/packages/modules/src/system-api-control-plane.ts +17 -1
  159. package/template/packages/modules/src/system-audit-actions.ts +10 -0
  160. package/template/packages/modules/src/system-features.ts +8 -0
  161. package/template/packages/modules/src/system-module-catalog.test.ts +35 -0
  162. package/template/packages/modules/src/system-module-catalog.ts +15 -4
  163. package/template/packages/modules/src/system-modules-access.test.ts +69 -0
  164. package/template/packages/modules/src/system-modules.ts +13 -0
  165. package/template/packages/modules/src/system-permissions.ts +2 -0
  166. package/template/packages/server-sdk/index.ts +11 -0
  167. package/template/packages/server-sdk/package.json +2 -1
  168. package/template/packages/server-sdk/src/ai-operation-port.ts +5 -0
  169. package/template/packages/server-sdk/src/debug-session-provider.ts +63 -0
  170. package/template/packages/server-sdk/src/module-host.ts +5 -0
  171. package/template/packages/shared/index.ts +3 -0
  172. package/template/packages/shared/package.json +1 -1
  173. package/template/packages/shared/src/ai-runtime-report.ts +134 -0
  174. package/template/scripts/lib/postgres-regression.ts +201 -0
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Literal, cast
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from southwind_agent_server.app import create_app
10
+ from southwind_agent_server.contracts import StartRunRequest
11
+ from southwind_agent_server.deterministic import DeterministicEngineAdapter
12
+ from southwind_agent_server.engine import EngineCompleted
13
+ from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
14
+ from southwind_agent_server.report_store import InMemoryAgentRuntimeReportStore
15
+ from southwind_agent_server.report_types import (
16
+ AttemptReport,
17
+ AttemptTerminalOutcome,
18
+ UsageFacts,
19
+ )
20
+ from southwind_agent_server.tool_host import ToolHostClient
21
+ from tests.openai_provider_test_support import (
22
+ create_provider,
23
+ governance,
24
+ runtime_request,
25
+ sse_response,
26
+ )
27
+
28
+
29
+ async def test_report_store_outage_does_not_rewrite_canonical_terminal() -> None:
30
+ report_store = UnavailableRunReportStore()
31
+ application = create_app(
32
+ DeterministicEngineAdapter(),
33
+ heartbeat_seconds=0.01,
34
+ report_store=report_store,
35
+ )
36
+ async with httpx.AsyncClient(
37
+ transport=httpx.ASGITransport(app=application),
38
+ base_url="http://agent.test",
39
+ ) as client:
40
+ response = await client.post(
41
+ "/api/agent/runs",
42
+ headers={"Idempotency-Key": "report-store-outage"},
43
+ json={"operationKey": "test.echo", "input": {"text": "completed"}},
44
+ )
45
+ run_id = str(cast(dict[str, object], response.json())["runId"])
46
+ event_response = await client.get(f"/api/agent/runs/{run_id}/events")
47
+
48
+ emitted = events(event_response)
49
+ assert emitted[-1]["type"] == "run.completed"
50
+ assert "run.failed" not in {event["type"] for event in emitted}
51
+ rendered = report_store.metrics.render()
52
+ for stage in ("run_ensure", "run_start", "run_terminal"):
53
+ assert (
54
+ f'southwind_agent_runtime_reporting_failures_total{{stage="{stage}"}} 1'
55
+ in rendered
56
+ )
57
+
58
+
59
+ @pytest.mark.parametrize("failure_stage", ["attempt_start", "attempt_terminal"])
60
+ async def test_report_store_outage_does_not_block_model_request(
61
+ failure_stage: Literal["attempt_start", "attempt_terminal"],
62
+ ) -> None:
63
+ provider_calls = 0
64
+
65
+ async def provider_handler(_request: httpx.Request) -> httpx.Response:
66
+ nonlocal provider_calls
67
+ provider_calls += 1
68
+ return sse_response([{"choices": [{"delta": {"content": "完成"}}]}])
69
+
70
+ report_store = FailingAttemptReportStore(failure_stage)
71
+ engine = OpenAiCompatibleEngineAdapter(
72
+ create_provider(httpx.MockTransport(provider_handler)),
73
+ ToolHostClient("internal-token"),
74
+ governance(),
75
+ report_store,
76
+ )
77
+
78
+ signals = [
79
+ signal
80
+ async for signal in engine.execute(runtime_request("business.records.list"))
81
+ ]
82
+
83
+ assert provider_calls == 1
84
+ assert isinstance(signals[-1], EngineCompleted)
85
+ assert (
86
+ "southwind_agent_runtime_reporting_failures_total"
87
+ f'{{stage="{failure_stage}"}} 1' in report_store.metrics.render()
88
+ )
89
+
90
+
91
+ class UnavailableRunReportStore(InMemoryAgentRuntimeReportStore):
92
+ async def ensure_run(
93
+ self,
94
+ run_id: str,
95
+ request: StartRunRequest,
96
+ created_at: float,
97
+ ) -> None:
98
+ raise RuntimeError("report store unavailable")
99
+
100
+ async def start_run(self, run_id: str, started_at: float) -> None:
101
+ raise RuntimeError("report store unavailable")
102
+
103
+ async def set_run_status(
104
+ self,
105
+ run_id: str,
106
+ status: Literal["running", "awaiting-approval"],
107
+ updated_at: float,
108
+ ) -> None:
109
+ raise RuntimeError("report store unavailable")
110
+
111
+ async def finish_run(
112
+ self,
113
+ run_id: str,
114
+ status: Literal["completed", "failed", "cancelled"],
115
+ finished_at: float,
116
+ safe_error_code: str | None = None,
117
+ ) -> None:
118
+ raise RuntimeError("report store unavailable")
119
+
120
+
121
+ class FailingAttemptReportStore(InMemoryAgentRuntimeReportStore):
122
+ def __init__(
123
+ self,
124
+ failure_stage: Literal["attempt_start", "attempt_terminal"],
125
+ ) -> None:
126
+ super().__init__()
127
+ self.failure_stage = failure_stage
128
+
129
+ async def start_attempt(
130
+ self,
131
+ run_id: str,
132
+ tool_round: int,
133
+ deployment_key: str,
134
+ requested_model: str,
135
+ started_at: float,
136
+ ) -> AttemptReport:
137
+ if self.failure_stage == "attempt_start":
138
+ raise RuntimeError("report store unavailable")
139
+ return await super().start_attempt(
140
+ run_id,
141
+ tool_round,
142
+ deployment_key,
143
+ requested_model,
144
+ started_at,
145
+ )
146
+
147
+ async def finish_attempt(
148
+ self,
149
+ attempt_id: str,
150
+ outcome: AttemptTerminalOutcome,
151
+ usage: UsageFacts,
152
+ finished_at: float,
153
+ reported_model: str | None = None,
154
+ safe_error_code: str | None = None,
155
+ ) -> None:
156
+ if self.failure_stage == "attempt_terminal":
157
+ raise RuntimeError("report store unavailable")
158
+ await super().finish_attempt(
159
+ attempt_id,
160
+ outcome,
161
+ usage,
162
+ finished_at,
163
+ reported_model,
164
+ safe_error_code,
165
+ )
166
+
167
+
168
+ def events(response: httpx.Response) -> list[dict[str, object]]:
169
+ result: list[dict[str, object]] = []
170
+ for frame in response.text.split("\n\n"):
171
+ data = next(
172
+ (
173
+ line.removeprefix("data: ")
174
+ for line in frame.splitlines()
175
+ if line.startswith("data: ")
176
+ ),
177
+ None,
178
+ )
179
+ if data is not None:
180
+ result.append(json.loads(data))
181
+ return result
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ import pytest
6
+
7
+ from southwind_agent_server.contracts import (
8
+ RunReportingContext,
9
+ StartRunRequest,
10
+ )
11
+ from southwind_agent_server.postgres_report_insert import (
12
+ RUN_INSERT_SQL,
13
+ run_insert_values,
14
+ )
15
+ from southwind_agent_server.report_lifecycle import sync_status
16
+ from southwind_agent_server.report_store import InMemoryAgentRuntimeReportStore
17
+ from southwind_agent_server.report_types import ReportFilter, UsageFacts
18
+
19
+
20
+ def request(actor_id: str) -> StartRunRequest:
21
+ return StartRunRequest(
22
+ operation_key="business.answer",
23
+ input={"secret": "must-not-be-reported"},
24
+ reporting=RunReportingContext(
25
+ actor_id=actor_id,
26
+ actor_type="user",
27
+ department_id="department_1",
28
+ ),
29
+ )
30
+
31
+
32
+ def test_postgres_run_insert_binds_every_persisted_timestamp() -> None:
33
+ values = run_insert_values("run_1", request("actor_1"), 100.0, 90)
34
+
35
+ assert RUN_INSERT_SQL.count("%s") == len(values)
36
+ assert values[-3:] == (100.0, 100.0, 7_776_100.0)
37
+
38
+
39
+ async def test_summary_rates_exclude_cancelled_and_unknown_cache() -> None:
40
+ store = InMemoryAgentRuntimeReportStore(now=lambda: 400.0)
41
+ values: tuple[tuple[str, str, Literal["completed", "failed", "cancelled"]], ...] = (
42
+ ("run_completed", "actor_1", "completed"),
43
+ ("run_failed", "actor_2", "failed"),
44
+ ("run_cancelled", "actor_3", "cancelled"),
45
+ )
46
+ for run_id, actor_id, status in values:
47
+ await store.ensure_run(run_id, request(actor_id), 100.0)
48
+ await store.start_run(run_id, 101.0)
49
+ await store.finish_run(run_id, status, 102.0)
50
+
51
+ attempts = (
52
+ ("run_completed", UsageFacts(cache_status="hit")),
53
+ ("run_failed", UsageFacts(cache_status="miss")),
54
+ ("run_cancelled", UsageFacts()),
55
+ )
56
+ for run_id, usage in attempts:
57
+ attempt = await store.start_attempt(
58
+ run_id,
59
+ 1,
60
+ "ai.openai-compatible",
61
+ "requested-model",
62
+ 101.0,
63
+ )
64
+ await store.finish_attempt(
65
+ attempt.attempt_id,
66
+ "completed",
67
+ usage,
68
+ 102.0,
69
+ )
70
+
71
+ summary = await store.summary(ReportFilter(0, 500))
72
+
73
+ assert summary["runs"] == {
74
+ "total": 3,
75
+ "completed": 1,
76
+ "failed": 1,
77
+ "cancelled": 1,
78
+ "inProgress": 0,
79
+ "successRate": 0.5,
80
+ }
81
+ assert summary["cache"] == {
82
+ "hit": 1,
83
+ "miss": 1,
84
+ "unknown": 1,
85
+ "hitRate": 0.5,
86
+ "tokenRate": None,
87
+ "coverageRate": 2 / 3,
88
+ }
89
+
90
+
91
+ async def test_approval_status_transitions_and_terminal_never_regresses() -> None:
92
+ store = InMemoryAgentRuntimeReportStore(now=lambda: 400.0)
93
+ await store.ensure_run("run_1", request("actor_1"), 100.0)
94
+ await store.start_run("run_1", 101.0)
95
+
96
+ await sync_status(store, "run_1", "approval.required")
97
+ waiting = await store.get_run("run_1")
98
+ assert waiting is not None
99
+ assert waiting["status"] == "awaiting-approval"
100
+
101
+ await sync_status(store, "run_1", "approval.resolved")
102
+ resumed = await store.get_run("run_1")
103
+ assert resumed is not None
104
+ assert resumed["status"] == "running"
105
+
106
+ await store.finish_run("run_1", "completed", 104.0)
107
+ await sync_status(store, "run_1", "approval.required")
108
+ terminal = await store.get_run("run_1")
109
+ assert terminal is not None
110
+ assert terminal["status"] == "completed"
111
+
112
+
113
+ async def test_unknown_usage_stays_null_and_empty_history_has_no_origin() -> None:
114
+ empty = InMemoryAgentRuntimeReportStore(now=lambda: 400.0)
115
+ empty_summary = await empty.summary(ReportFilter(0, 500))
116
+ assert empty_summary["reportingAvailableFrom"] is None
117
+
118
+ store = InMemoryAgentRuntimeReportStore(now=lambda: 400.0)
119
+ await store.ensure_run("run_1", request("actor_secret"), 100.0)
120
+ attempt = await store.start_attempt(
121
+ "run_1",
122
+ 1,
123
+ "ai.openai-compatible",
124
+ "requested-model",
125
+ 101.0,
126
+ )
127
+ await store.finish_attempt(
128
+ attempt.attempt_id,
129
+ "completed",
130
+ UsageFacts(),
131
+ 102.0,
132
+ )
133
+
134
+ detail = await store.get_run("run_1")
135
+
136
+ assert detail is not None
137
+ assert detail["usage"] == {
138
+ "inputTokens": None,
139
+ "outputTokens": None,
140
+ "cachedInputTokens": None,
141
+ "reasoningTokens": None,
142
+ "totalTokens": None,
143
+ }
144
+ rendered_metrics = store.metrics.render()
145
+ assert "actor_secret" not in rendered_metrics
146
+ assert "requested-model" not in rendered_metrics
147
+
148
+
149
+ async def test_reporting_origin_is_limited_to_filter_and_scope() -> None:
150
+ store = InMemoryAgentRuntimeReportStore(now=lambda: 400.0)
151
+ await store.ensure_run("run_other", request("actor_other"), 100.0)
152
+ await store.ensure_run("run_visible", request("actor_visible"), 200.0)
153
+
154
+ selected = await store.summary(
155
+ ReportFilter(0, 500, selected_actor_id="actor_visible")
156
+ )
157
+ scoped = await store.summary(
158
+ ReportFilter(
159
+ 0,
160
+ 500,
161
+ scope_restricted=True,
162
+ actor_ids=("actor_visible",),
163
+ )
164
+ )
165
+ empty = await store.summary(
166
+ ReportFilter(
167
+ 0,
168
+ 500,
169
+ scope_restricted=True,
170
+ actor_ids=("actor_missing",),
171
+ )
172
+ )
173
+
174
+ assert selected["reportingAvailableFrom"] == "1970-01-01T00:03:20Z"
175
+ assert scoped["reportingAvailableFrom"] == "1970-01-01T00:03:20Z"
176
+ assert empty["reportingAvailableFrom"] is None
177
+
178
+
179
+ async def test_attempt_terminal_replay_rejects_changed_facts() -> None:
180
+ store = InMemoryAgentRuntimeReportStore()
181
+ attempt = await store.start_attempt(
182
+ "run_1",
183
+ 1,
184
+ "ai.openai-compatible",
185
+ "requested-model",
186
+ 100.0,
187
+ )
188
+ usage = UsageFacts(
189
+ source="provider-reported",
190
+ input_tokens=10,
191
+ total_tokens=10,
192
+ )
193
+ await store.finish_attempt(
194
+ attempt.attempt_id,
195
+ "completed",
196
+ usage,
197
+ 101.0,
198
+ "reported-model",
199
+ )
200
+ await store.finish_attempt(
201
+ attempt.attempt_id,
202
+ "completed",
203
+ usage,
204
+ 102.0,
205
+ "reported-model",
206
+ )
207
+
208
+ with pytest.raises(ValueError, match="Attempt 终态冲突"):
209
+ await store.finish_attempt(
210
+ attempt.attempt_id,
211
+ "completed",
212
+ usage,
213
+ 103.0,
214
+ "different-model",
215
+ )
216
+
217
+
218
+ async def test_token_rates_only_use_attempts_with_matching_known_facts() -> None:
219
+ store = InMemoryAgentRuntimeReportStore(now=lambda: 400.0)
220
+ await store.ensure_run("run_1", request("actor_1"), 100.0)
221
+ for usage in (
222
+ UsageFacts(
223
+ source="provider-reported",
224
+ input_tokens=10,
225
+ cached_input_tokens=None,
226
+ total_tokens=None,
227
+ cache_status="unknown",
228
+ ),
229
+ UsageFacts(
230
+ source="provider-reported",
231
+ input_tokens=10,
232
+ cached_input_tokens=5,
233
+ total_tokens=15,
234
+ cache_status="hit",
235
+ ),
236
+ ):
237
+ attempt = await store.start_attempt(
238
+ "run_1",
239
+ 1,
240
+ "ai.openai-compatible",
241
+ "requested-model",
242
+ 101.0,
243
+ )
244
+ await store.finish_attempt(
245
+ attempt.attempt_id,
246
+ "completed",
247
+ usage,
248
+ 102.0,
249
+ )
250
+
251
+ summary = await store.summary(ReportFilter(0, 500))
252
+
253
+ assert summary["cache"]["tokenRate"] == 0.5 # type: ignore[index]
254
+ assert summary["tokens"]["coverageRate"] == 0.5 # type: ignore[index]
255
+ assert summary["activity"][0]["totalTokens"] is None # type: ignore[index]
256
+
257
+
258
+ async def test_completely_unknown_aggregate_tokens_stay_null() -> None:
259
+ store = InMemoryAgentRuntimeReportStore(now=lambda: 400.0)
260
+ await store.ensure_run("run_1", request("actor_1"), 100.0)
261
+ attempt = await store.start_attempt(
262
+ "run_1",
263
+ 1,
264
+ "ai.openai-compatible",
265
+ "requested-model",
266
+ 101.0,
267
+ )
268
+ await store.finish_attempt(
269
+ attempt.attempt_id,
270
+ "completed",
271
+ UsageFacts(),
272
+ 102.0,
273
+ )
274
+
275
+ summary = await store.summary(ReportFilter(0, 500))
276
+
277
+ assert summary["tokens"] == {
278
+ "input": None,
279
+ "output": None,
280
+ "cachedInput": None,
281
+ "reasoning": None,
282
+ "total": None,
283
+ "coverageRate": 0,
284
+ }
285
+ assert summary["byModel"][0]["totalTokens"] is None # type: ignore[index]
286
+ assert summary["activity"][0]["totalTokens"] is None # type: ignore[index]
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from southwind_agent_server.postgres_report_summary import present_summary
4
+ from southwind_agent_server.report_presenter import aggregate_summary
5
+ from southwind_agent_server.report_types import (
6
+ AttemptReport,
7
+ RunReport,
8
+ UsageFacts,
9
+ )
10
+
11
+
12
+ def test_postgres_and_in_memory_summary_present_same_dual_timeline() -> None:
13
+ run = RunReport(
14
+ run_id="run_1",
15
+ operation_key="business.answer",
16
+ release_id="release_1",
17
+ trace_id="trace_1",
18
+ actor_id="actor_1",
19
+ actor_type="user",
20
+ department_id=None,
21
+ status="completed",
22
+ safe_error_code=None,
23
+ started_at=1,
24
+ finished_at=2,
25
+ duration_ms=1_000,
26
+ created_at=0,
27
+ updated_at=2,
28
+ expires_at=1_000,
29
+ )
30
+ usage = UsageFacts(
31
+ source="provider-reported",
32
+ input_tokens=10,
33
+ output_tokens=2,
34
+ cached_input_tokens=5,
35
+ total_tokens=12,
36
+ cache_status="hit",
37
+ )
38
+ attempt = AttemptReport(
39
+ attempt_id="attempt_1",
40
+ run_id="run_1",
41
+ sequence=1,
42
+ tool_round=1,
43
+ deployment_key="ai.openai-compatible",
44
+ requested_model="requested",
45
+ reported_model="reported",
46
+ outcome="completed",
47
+ usage=usage,
48
+ duration_ms=1_000,
49
+ safe_error_code=None,
50
+ started_at=172_800,
51
+ finished_at=172_801,
52
+ )
53
+
54
+ memory = aggregate_summary([run], [attempt], 90, 0, 400)
55
+ postgres = present_summary(
56
+ {
57
+ "total": 1,
58
+ "completed": 1,
59
+ "failed": 0,
60
+ "cancelled": 0,
61
+ "available_from": 0,
62
+ },
63
+ {
64
+ "attempts": 1,
65
+ "available_from": 172_800,
66
+ "cache_hit": 1,
67
+ "cache_miss": 0,
68
+ "cache_unknown": 0,
69
+ "known_total_tokens": 1,
70
+ "input_tokens": 10,
71
+ "output_tokens": 2,
72
+ "cached_input_tokens": 5,
73
+ "reasoning_tokens": None,
74
+ "total_tokens": 12,
75
+ "paired_input_tokens": 10,
76
+ "paired_cached_tokens": 5,
77
+ },
78
+ [
79
+ {
80
+ "deployment_key": "ai.openai-compatible",
81
+ "model": "reported",
82
+ "attempts": 1,
83
+ "input_tokens": 10,
84
+ "output_tokens": 2,
85
+ "cached_input_tokens": 5,
86
+ "reasoning_tokens": None,
87
+ "total_tokens": 12,
88
+ "unknown_usage_count": 0,
89
+ }
90
+ ],
91
+ [
92
+ {
93
+ "date": "1970-01-01",
94
+ "runs": 1,
95
+ "completed": 1,
96
+ "failed": 0,
97
+ "attempts": 0,
98
+ "known_token_attempts": 0,
99
+ "total_tokens": None,
100
+ },
101
+ {
102
+ "date": "1970-01-03",
103
+ "runs": 0,
104
+ "completed": 0,
105
+ "failed": 0,
106
+ "attempts": 1,
107
+ "known_token_attempts": 1,
108
+ "total_tokens": 12,
109
+ },
110
+ ],
111
+ 90,
112
+ 400,
113
+ )
114
+
115
+ assert postgres == memory