create-windy 0.8.0 → 0.8.2

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 (50) hide show
  1. package/dist/cli.js +14 -8
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/Dockerfile +1 -1
  5. package/template/apps/agent-server/README.md +1 -1
  6. package/template/apps/agent-server/src/southwind_agent_server/asgi.py +8 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
  8. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
  9. package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/runtime_logging.py +57 -0
  11. package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +145 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/telemetry.py +4 -0
  13. package/template/apps/agent-server/tests/test_run_operational_logging.py +114 -0
  14. package/template/apps/agent-server/tests/test_structured_logging.py +142 -0
  15. package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
  16. package/template/apps/server/src/agent/isolated-execution-guard.test.ts +194 -0
  17. package/template/apps/server/src/agent/isolated-execution-guard.ts +202 -0
  18. package/template/apps/server/src/agent/run-definition-support.ts +11 -1
  19. package/template/apps/server/src/agent/run-facade.ts +7 -11
  20. package/template/apps/server/src/agent/run-operation-policy.ts +11 -0
  21. package/template/apps/server/src/agent/workflow-durable-runtime-input.ts +8 -5
  22. package/template/apps/server/src/jobs/legacy-service-runtime.ts +4 -0
  23. package/template/apps/server/src/jobs/runtime-logging.test.ts +212 -0
  24. package/template/apps/server/src/jobs/runtime-logging.ts +58 -0
  25. package/template/apps/server/src/jobs/worker.ts +54 -12
  26. package/template/apps/server/src/observability/bootstrap.ts +1 -4
  27. package/template/apps/server/src/observability/http-observability.ts +2 -3
  28. package/template/apps/server/src/observability/structured-logger.test.ts +31 -0
  29. package/template/apps/server/src/observability/structured-logger.ts +27 -0
  30. package/template/apps/server/src/platform-bootstrap.ts +9 -3
  31. package/template/apps/server/src/startup-finalization.ts +3 -3
  32. package/template/apps/server/src/system/built-in-roles.test.ts +23 -0
  33. package/template/apps/server/src/system/built-in-roles.ts +10 -4
  34. package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +16 -5
  35. package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +4 -0
  36. package/template/docs/architecture/log-governance-implementation.md +96 -0
  37. package/template/docs/architecture/log-governance-plan.md +39 -0
  38. package/template/docs/operations/observability.md +36 -4
  39. package/template/docs/platform/agent-runtime.md +18 -0
  40. package/template/packages/modules/package.json +1 -1
  41. package/template/packages/modules/src/ai-isolated-execution-policy.test.ts +70 -0
  42. package/template/packages/modules/src/ai-isolated-execution-policy.ts +76 -0
  43. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  44. package/template/packages/modules/src/manifest.ts +23 -0
  45. package/template/packages/modules/src/system-features.ts +11 -10
  46. package/template/packages/server-sdk/package.json +1 -1
  47. package/template/packages/shared/index.ts +1 -0
  48. package/template/packages/shared/package.json +3 -2
  49. package/template/packages/shared/src/structured-log.test.ts +147 -0
  50. package/template/packages/shared/src/structured-log.ts +215 -0
package/dist/cli.js CHANGED
@@ -2022,7 +2022,14 @@ var nativeSymbols = {
2022
2022
  };
2023
2023
  var directoryFlags = constants2.O_RDONLY | constants2.O_DIRECTORY | constants2.O_NOFOLLOW;
2024
2024
  var removeDirectoryFlag = process.platform === "darwin" ? 128 : 512;
2025
- var getLibc = createLazyLibcLoader((candidate) => dlopen2(candidate, nativeSymbols), "\u5F53\u524D\u5E73\u53F0\u65E0\u6CD5\u52A0\u8F7D\u5B89\u5168\u76EE\u5F55\u53E5\u67C4 API");
2025
+ var getLibc = createLazyLibcLoader((candidate) => {
2026
+ const libc = dlopen2(candidate, nativeSymbols);
2027
+ const errnoSymbol = process.platform === "darwin" ? libc.symbols.__error : libc.symbols.__errno_location;
2028
+ const errnoPointer = errnoSymbol();
2029
+ if (!errnoPointer)
2030
+ throw new Error("\u65E0\u6CD5\u8BFB\u53D6\u539F\u751F\u9519\u8BEF\u7801\uFF0C\u4E8B\u52A1\u5B89\u5168\u5173\u95ED");
2031
+ return { symbols: libc.symbols, errnoPointer };
2032
+ }, "\u5F53\u524D\u5E73\u53F0\u65E0\u6CD5\u52A0\u8F7D\u5B89\u5168\u76EE\u5F55\u53E5\u67C4 API");
2026
2033
  function resolveSafeTarget(root, path) {
2027
2034
  if (!path || isAbsolute(path) || path.includes("\x00") || path.split(/[\\/]/).includes("..")) {
2028
2035
  throw new Error(`\u66F4\u65B0\u8BA1\u5212\u5305\u542B\u4E0D\u5B89\u5168\u8DEF\u5F84\uFF1A${path}`);
@@ -2196,12 +2203,7 @@ function nativeUnlinkAt(directoryFd, name, flags) {
2196
2203
  }
2197
2204
  function nativeError(syscall, path) {
2198
2205
  const libc = getLibc();
2199
- const errnoSymbol = process.platform === "darwin" ? libc.symbols.__error : libc.symbols.__errno_location;
2200
- const errnoPointer = errnoSymbol();
2201
- if (!errnoPointer) {
2202
- throw new Error(`\u65E0\u6CD5\u8BFB\u53D6 ${syscall} \u7684\u539F\u751F\u9519\u8BEF\u7801\uFF0C\u4E8B\u52A1\u5B89\u5168\u5173\u95ED`);
2203
- }
2204
- const errno = read.i32(errnoPointer, 0);
2206
+ const errno = read.i32(libc.errnoPointer, 0);
2205
2207
  return Object.assign(new Error(`${syscall} ${path} \u5931\u8D25`), {
2206
2208
  code: errnoCode(errno),
2207
2209
  errno: -errno,
@@ -4107,6 +4109,8 @@ var templateDocumentationFiles = [
4107
4109
  "docs/architecture/mcp-business-capabilities.md",
4108
4110
  "docs/architecture/migration-bundle.md",
4109
4111
  "docs/architecture/module-host.md",
4112
+ "docs/architecture/log-governance-plan.md",
4113
+ "docs/architecture/log-governance-implementation.md",
4110
4114
  "docs/architecture/module-manifest-composition.md",
4111
4115
  "docs/architecture/module-secret-resolution.md",
4112
4116
  "docs/architecture/object-storage.md",
@@ -8524,7 +8528,9 @@ var recipes = [
8524
8528
  id: "starter-0.7.0-to-0.8.0",
8525
8529
  from: "0.7.0",
8526
8530
  to: "0.8.0"
8527
- }
8531
+ },
8532
+ { id: "starter-0.8.0-to-0.8.1", from: "0.8.0", to: "0.8.1" },
8533
+ { id: "starter-0.8.1-to-0.8.2", from: "0.8.1", to: "0.8.2" }
8528
8534
  ];
8529
8535
  function resolveRecipeChain(sourceVersion, targetVersion) {
8530
8536
  if (compareVersions2(sourceVersion, minimumSupportedUpdateVersion) < 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "20ddeda7267de315fb8fe115d64ad6b8c892ccb8",
4
- "generatorVersion": "0.8.0",
3
+ "templateCommit": "7c00e145cc8fef9f4c6ec621e8740e3c01f7cc2d",
4
+ "generatorVersion": "0.8.2",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -24,4 +24,4 @@ RUN mkdir -p /app/data/agent \
24
24
  USER windy
25
25
  HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
26
26
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
27
- CMD ["uvicorn", "southwind_agent_server.app:app", "--host", "0.0.0.0", "--port", "8080"]
27
+ CMD ["uvicorn", "southwind_agent_server.asgi:app", "--host", "0.0.0.0", "--port", "8080"]
@@ -43,7 +43,7 @@ export AI_PROVIDER_INPUT_MODALITIES='text'
43
43
  export AGENT_RUN_STORE_PATH='.data/agent-runs.sqlite3'
44
44
  export AI_PROVIDER_AUDIT_STORE_PATH='.data/provider-audit.sqlite3'
45
45
  export AI_PROVIDER_AUDIT_RETENTION_DAYS='90'
46
- uv run uvicorn southwind_agent_server.app:app
46
+ uv run uvicorn southwind_agent_server.asgi:app
47
47
  ```
48
48
 
49
49
  公网 URL 必须使用 HTTPS 且 Host 精确命中 allowlist;本机、容器服务名和内网地址允许
@@ -0,0 +1,8 @@
1
+ """Production ASGI entrypoint with operational logging installed."""
2
+
3
+ from .app import app
4
+ from .structured_logging import configure_agent_logging
5
+
6
+ configure_agent_logging()
7
+
8
+ __all__ = ["app"]
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, replace
4
4
 
5
5
  from .attempt_context_projection import (
6
6
  AttemptContextPreparation,
@@ -15,6 +15,7 @@ from .contracts import JsonValue, LongSessionContext
15
15
  from .engine import EngineRunRequest, PreparedArtifact
16
16
  from .openai_provider import OpenAiCompatibleProvider
17
17
  from .provider_governance import GovernedAttempt, ProviderRequestGovernance
18
+ from .provider_output_guidance import tool_output_messages
18
19
 
19
20
 
20
21
  @dataclass(frozen=True)
@@ -64,6 +65,13 @@ async def govern_provider_attempt(
64
65
  )
65
66
  ),
66
67
  )
68
+ if governed.tools:
69
+ governed = replace(
70
+ governed,
71
+ messages=tool_output_messages(
72
+ governed.messages, governed.output_schema
73
+ ),
74
+ )
67
75
  provider_request = provider.request_body(
68
76
  governed.messages,
69
77
  governed.tools,
@@ -14,6 +14,7 @@ from .config import (
14
14
  from .contracts import AgentErrorCode, JsonValue, RuntimeToolDefinition
15
15
  from .engine import SafeEngineError
16
16
  from .multimodal import response_format
17
+ from .provider_output_guidance import tool_output_messages
17
18
  from .report_types import UsageFacts
18
19
  from .vision_probe import probe_vision
19
20
 
@@ -97,6 +98,9 @@ class OpenAiCompatibleProvider:
97
98
  output_schema: dict[str, JsonValue] | None = None,
98
99
  max_output_tokens: int | None = None,
99
100
  ) -> bytes:
101
+ structured = response_format(output_schema_key, output_schema)
102
+ if tools:
103
+ messages = tool_output_messages(messages, output_schema)
100
104
  payload: dict[str, JsonValue] = {
101
105
  "model": self.config.model,
102
106
  "messages": cast(JsonValue, messages),
@@ -105,8 +109,7 @@ class OpenAiCompatibleProvider:
105
109
  }
106
110
  if tools:
107
111
  payload["tools"] = [tool_schema(tool) for tool in tools]
108
- structured = response_format(output_schema_key, output_schema)
109
- if structured is not None:
112
+ if structured is not None and not tools:
110
113
  payload["response_format"] = structured
111
114
  if max_output_tokens is not None:
112
115
  payload["max_tokens"] = max_output_tokens
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .contracts import JsonValue
6
+ from .engine import SafeEngineError
7
+
8
+
9
+ def tool_output_messages(
10
+ messages: list[dict[str, JsonValue]],
11
+ output_schema: dict[str, JsonValue] | None,
12
+ ) -> list[dict[str, JsonValue]]:
13
+ """Merge already-governed schema guidance without moving Context source ordinals."""
14
+ if output_schema is None:
15
+ return messages
16
+ if not messages or messages[0].get("role") != "system":
17
+ raise SafeEngineError(
18
+ "INVALID_INPUT", "工具结构化输出缺少系统指令", retryable=False
19
+ )
20
+ instructions = messages[0].get("content")
21
+ if not isinstance(instructions, str):
22
+ raise SafeEngineError(
23
+ "INVALID_INPUT", "工具结构化输出指令无效", retryable=False
24
+ )
25
+ guidance = (
26
+ "\n\n需要工具时,只通过 API 的 function calling 调用已提供的工具,"
27
+ "不要在正文中模拟工具调用。工具处理完成后的最终回复必须是符合以下 "
28
+ "JSON Schema 的单个 JSON 值,不含 Markdown 围栏或额外说明;"
29
+ "工具调用阶段不受最终 JSON 格式限制。\n"
30
+ + json.dumps(output_schema, ensure_ascii=False, separators=(",", ":"))
31
+ )
32
+ if instructions.endswith(guidance):
33
+ return messages
34
+ return [{**messages[0], "content": instructions + guidance}, *messages[1:]]
@@ -0,0 +1,57 @@
1
+ """Safe attempt diagnostics, separate from the canonical Run store and audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+
8
+ from .store import RunRecord
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class AgentOperationalAttempt:
14
+ def __init__(self, record: RunRecord) -> None:
15
+ self._run_id = record.run_id
16
+ self._trace_id = record.trace_id
17
+ self._started_at: float | None = None
18
+ self._finished = False
19
+
20
+ def start(self) -> None:
21
+ if self._started_at is not None:
22
+ return
23
+ self._started_at = time.monotonic()
24
+ self._emit("agent.run.attempt.started")
25
+
26
+ def finish(self, status: str) -> None:
27
+ if self._started_at is None or self._finished:
28
+ return
29
+ self._finished = True
30
+ self._emit(
31
+ "agent.run.attempt.completed",
32
+ operational_status=status,
33
+ operational_duration_ms=round(
34
+ (time.monotonic() - self._started_at) * 1_000, 3
35
+ ),
36
+ )
37
+
38
+ def _emit(self, event: str, **fields: str | float) -> None:
39
+ try:
40
+ emit = (
41
+ logger.warning
42
+ if event == "agent.run.attempt.completed"
43
+ and fields.get("operational_status") != "completed"
44
+ else logger.info
45
+ )
46
+ emit(
47
+ "Agent 执行阶段",
48
+ extra={
49
+ "operational_event": event,
50
+ "run_id": self._run_id,
51
+ "trace_id": self._trace_id,
52
+ **fields,
53
+ },
54
+ )
55
+ except Exception:
56
+ # 日志出口失败不能改变 Run 或租约的业务结果。
57
+ return
@@ -0,0 +1,145 @@
1
+ """Agent runtime adapter for the Windy operational log contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import math
8
+ import re
9
+ import sys
10
+ from datetime import UTC, datetime
11
+ from typing import TextIO
12
+
13
+ MAX_LOG_BYTES = 2048
14
+ _CORRELATION = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
15
+ _STABLE = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
16
+ _EVENTS = {
17
+ "Agent Run 恢复轮询失败,将继续重试": (
18
+ "agent.run.recovery.failed",
19
+ "AGENT_RUN_RECOVERY_FAILED",
20
+ ),
21
+ "Agent Runtime 报表写入失败": (
22
+ "agent.report.write.failed",
23
+ "AGENT_REPORT_WRITE_FAILED",
24
+ ),
25
+ }
26
+ _ATTEMPT_EVENTS = frozenset(
27
+ {
28
+ "agent.run.attempt.started",
29
+ "agent.run.attempt.completed",
30
+ }
31
+ )
32
+ _ATTEMPT_ERRORS = {
33
+ "failed": "AGENT_RUN_FAILED",
34
+ "cancelled": "AGENT_RUN_CANCELLED",
35
+ "interrupted": "AGENT_RUN_INTERRUPTED",
36
+ }
37
+
38
+
39
+ def agent_log_record(record: logging.LogRecord) -> dict[str, str | bool | float]:
40
+ """Map known runtime failures without serializing messages, errors or extras."""
41
+ event, error_code = _EVENTS.get(
42
+ record.msg if isinstance(record.msg, str) else "",
43
+ ("agent.runtime.failed", "AGENT_RUNTIME_FAILED"),
44
+ )
45
+ attempt_event = getattr(record, "operational_event", None)
46
+ is_attempt = isinstance(attempt_event, str) and attempt_event in _ATTEMPT_EVENTS
47
+ result: dict[str, str | bool | float] = {
48
+ "timestamp": datetime.now(UTC)
49
+ .isoformat(timespec="milliseconds")
50
+ .replace("+00:00", "Z"),
51
+ "level": "error"
52
+ if record.levelno >= logging.ERROR
53
+ else ("warn" if record.levelno >= logging.WARNING else "info"),
54
+ "component": "agent-server",
55
+ "event": attempt_event
56
+ if isinstance(attempt_event, str) and is_attempt
57
+ else event,
58
+ "retentionClass": "operational",
59
+ }
60
+ if not is_attempt:
61
+ result.update(outcome="failure", errorCode=error_code)
62
+ elif attempt_event == "agent.run.attempt.completed":
63
+ status = getattr(record, "operational_status", "interrupted")
64
+ if status not in {"completed", "failed", "cancelled"}:
65
+ status = "interrupted"
66
+ result["stageKey"] = status
67
+ result["outcome"] = "success" if status == "completed" else "failure"
68
+ if status != "completed":
69
+ result["errorCode"] = _ATTEMPT_ERRORS[status]
70
+ duration = getattr(record, "operational_duration_ms", None)
71
+ if (
72
+ isinstance(duration, (int, float))
73
+ and not isinstance(duration, bool)
74
+ and math.isfinite(duration)
75
+ and 0 <= duration <= 3_600_000
76
+ ):
77
+ result["durationMs"] = duration
78
+ for source, target in (
79
+ ("request_id", "requestId"),
80
+ ("run_id", "runId"),
81
+ ("job_id", "jobId"),
82
+ ("trace_id", "traceId"),
83
+ ):
84
+ value = getattr(record, source, None)
85
+ if isinstance(value, str) and _CORRELATION.fullmatch(value):
86
+ result[target] = value
87
+ stage = getattr(record, "report_stage", None)
88
+ if isinstance(stage, str) and len(stage) <= 64 and _STABLE.fullmatch(stage):
89
+ result["stageKey"] = stage
90
+ for key in ("stageKey", "traceId", "jobId", "runId", "requestId"):
91
+ if len(json.dumps(result, ensure_ascii=False).encode("utf-8")) <= MAX_LOG_BYTES:
92
+ break
93
+ result.pop(key, None)
94
+ result["truncated"] = True
95
+ return result
96
+
97
+
98
+ class AgentStructuredLogHandler(logging.Handler):
99
+ def __init__(self, stream: TextIO | None = None) -> None:
100
+ super().__init__(level=logging.INFO)
101
+ self.stream = stream or sys.stdout
102
+
103
+ def emit(self, record: logging.LogRecord) -> None:
104
+ try:
105
+ if (
106
+ record.levelno < logging.WARNING
107
+ and getattr(record, "operational_event", None) not in _ATTEMPT_EVENTS
108
+ ):
109
+ return
110
+ line = json.dumps(
111
+ agent_log_record(record), ensure_ascii=False, separators=(",", ":")
112
+ )
113
+ self.stream.write(f"{line}\n")
114
+ self.flush()
115
+ except Exception:
116
+ # 运行日志旁路失败时不能改变 Run 与报表写入的业务结果。
117
+ return
118
+
119
+ def flush(self) -> None:
120
+ try:
121
+ self.stream.flush()
122
+ except Exception:
123
+ return
124
+
125
+
126
+ def configure_agent_logging() -> None:
127
+ """Use one package sink without closing handlers shared with Uvicorn."""
128
+ logger = logging.getLogger("southwind_agent_server")
129
+ structured = next(
130
+ (
131
+ handler
132
+ for handler in logger.handlers
133
+ if isinstance(handler, AgentStructuredLogHandler)
134
+ ),
135
+ None,
136
+ )
137
+ if structured is None:
138
+ structured = AgentStructuredLogHandler()
139
+ for handler in logger.handlers[:]:
140
+ if handler is not structured:
141
+ logger.removeHandler(handler)
142
+ if structured not in logger.handlers:
143
+ logger.addHandler(structured)
144
+ logger.setLevel(logging.INFO)
145
+ logger.propagate = False
@@ -17,6 +17,7 @@ from opentelemetry.trace import (
17
17
  TraceFlags,
18
18
  )
19
19
 
20
+ from .runtime_logging import AgentOperationalAttempt
20
21
  from .store import RunRecord
21
22
 
22
23
  _configured = False
@@ -50,11 +51,14 @@ class AgentRunTrace:
50
51
  },
51
52
  )
52
53
  self._token: Token[Context] | None = None
54
+ self._operational = AgentOperationalAttempt(record)
53
55
 
54
56
  def start(self) -> None:
55
57
  self._token = context.attach(trace.set_span_in_context(self._span))
58
+ self._operational.start()
56
59
 
57
60
  def finish(self, status: str) -> None:
61
+ self._operational.finish(status)
58
62
  self._span.set_attribute("agent.run.status", status)
59
63
  if self._token is not None:
60
64
  context.detach(self._token)
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import io
5
+ import json
6
+ import logging
7
+ from collections.abc import Iterator
8
+ from typing import TextIO, cast
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from southwind_agent_server.app import create_app
14
+ from southwind_agent_server.deterministic import DeterministicEngineAdapter
15
+ from southwind_agent_server.runtime_logging import AgentOperationalAttempt, logger
16
+ from southwind_agent_server.service import AgentRunService
17
+ from southwind_agent_server.store import RunRecord
18
+ from southwind_agent_server.structured_logging import AgentStructuredLogHandler
19
+
20
+
21
+ @pytest.fixture(autouse=True)
22
+ def operational_log_level() -> Iterator[None]:
23
+ previous = logger.level
24
+ logger.setLevel(logging.INFO)
25
+ try:
26
+ yield
27
+ finally:
28
+ logger.setLevel(previous)
29
+
30
+
31
+ @pytest.mark.parametrize("broken_sink", [False, True])
32
+ async def test_real_run_completion_and_replay_with_operational_logs(
33
+ monkeypatch: pytest.MonkeyPatch,
34
+ broken_sink: bool,
35
+ ) -> None:
36
+ class Stream(io.StringIO):
37
+ def write(self, value: str) -> int:
38
+ written = super().write(value)
39
+ if broken_sink:
40
+ raise OSError("sink-secret")
41
+ return written
42
+
43
+ stream = Stream()
44
+ monkeypatch.setattr(logger, "handlers", [AgentStructuredLogHandler(stream)])
45
+ monkeypatch.setattr(logger, "propagate", False)
46
+ app = create_app(DeterministicEngineAdapter(), heartbeat_seconds=0.01)
47
+ service = cast(AgentRunService, app.state.run_service)
48
+ async with httpx.AsyncClient(
49
+ transport=httpx.ASGITransport(app=app),
50
+ base_url="http://agent.test",
51
+ ) as client:
52
+
53
+ async def start() -> httpx.Response:
54
+ return await client.post(
55
+ "/api/agent/runs",
56
+ headers={"Idempotency-Key": "operational-replay"},
57
+ json={
58
+ "operationKey": "test.echo",
59
+ "input": {"text": "input-secret"},
60
+ "traceId": "1" * 32,
61
+ },
62
+ )
63
+
64
+ response = await start()
65
+ assert response.status_code == 200
66
+ run_id = str(response.json()["runId"])
67
+ await asyncio.gather(*service._tasks)
68
+ replay = await start()
69
+ assert replay.json()["runId"] == run_id
70
+ events = await client.get(f"/api/agent/runs/{run_id}/events")
71
+ result = await client.get(f"/api/agent/runs/{run_id}/result")
72
+ assert result.json()["status"] == "completed"
73
+ assert result.json()["output"] == {"text": "input-secret"}
74
+ assert "run.failed" not in events.text
75
+ records = [json.loads(line) for line in stream.getvalue().splitlines()]
76
+ assert [record["event"] for record in records] == [
77
+ "agent.run.attempt.started",
78
+ "agent.run.attempt.completed",
79
+ ]
80
+ assert all(record["runId"] == run_id for record in records)
81
+ assert all(record["traceId"] == "1" * 32 for record in records)
82
+ assert records[-1]["outcome"] == "success"
83
+ assert records[-1]["durationMs"] >= 0
84
+ assert "input-secret" not in stream.getvalue()
85
+ assert "sink-secret" not in stream.getvalue()
86
+
87
+
88
+ @pytest.mark.parametrize("status", ["failed", "cancelled", "running"])
89
+ def test_attempt_terminal_mapping_and_duplicate_finish_are_safe(
90
+ monkeypatch: pytest.MonkeyPatch,
91
+ status: str,
92
+ ) -> None:
93
+ stream = io.StringIO()
94
+ monkeypatch.setattr(logger, "handlers", [AgentStructuredLogHandler(stream)])
95
+ monkeypatch.setattr(logger, "propagate", False)
96
+ record = RunRecord("run-1", "test.echo", {"secret": "input"}, "key", "digest")
97
+ attempt = AgentOperationalAttempt(record)
98
+ attempt.start()
99
+ attempt.start()
100
+ attempt.finish(status)
101
+ attempt.finish(status)
102
+ records = [json.loads(line) for line in stream.getvalue().splitlines()]
103
+ assert len(records) == 2
104
+ assert records[-1]["outcome"] == "failure"
105
+ expected = status if status in {"failed", "cancelled"} else "interrupted"
106
+ assert records[-1]["errorCode"] == f"AGENT_RUN_{expected.upper()}"
107
+ assert "secret" not in stream.getvalue()
108
+
109
+
110
+ def test_unknown_info_message_does_not_expand_log_content() -> None:
111
+ stream = io.StringIO()
112
+ handler = AgentStructuredLogHandler(cast(TextIO, stream))
113
+ handler.emit(logging.makeLogRecord({"levelno": logging.INFO, "msg": "secret"}))
114
+ assert stream.getvalue() == ""
@@ -0,0 +1,142 @@
1
+ import io
2
+ import json
3
+ import logging
4
+ from importlib import import_module
5
+ from typing import TextIO, cast
6
+
7
+ import pytest
8
+
9
+ from southwind_agent_server.structured_logging import (
10
+ MAX_LOG_BYTES,
11
+ AgentStructuredLogHandler,
12
+ agent_log_record,
13
+ configure_agent_logging,
14
+ )
15
+
16
+
17
+ def test_agent_report_failure_keeps_only_safe_correlation() -> None:
18
+ stream = io.StringIO()
19
+ handler = AgentStructuredLogHandler(stream)
20
+ logger = logging.getLogger("southwind_agent_server.test_structured_logging")
21
+ logger.addHandler(handler)
22
+ logger.setLevel(logging.WARNING)
23
+ logger.propagate = False
24
+ try:
25
+ try:
26
+ raise ValueError("secret-error")
27
+ except ValueError:
28
+ logger.exception(
29
+ "Agent Runtime 报表写入失败",
30
+ extra={
31
+ "run_id": "run-1",
32
+ "report_stage": "run_terminal",
33
+ "request_id": "bad\nrequest-secret",
34
+ "body": "secret-body",
35
+ "authorization": "Bearer secret-token",
36
+ },
37
+ )
38
+ finally:
39
+ logger.removeHandler(handler)
40
+ line = stream.getvalue().strip()
41
+ parsed = json.loads(line)
42
+ assert parsed["event"] == "agent.report.write.failed"
43
+ assert parsed["errorCode"] == "AGENT_REPORT_WRITE_FAILED"
44
+ assert parsed["runId"] == "run-1"
45
+ assert parsed["stageKey"] == "run_terminal"
46
+ assert parsed["retentionClass"] == "operational"
47
+ assert len(line.encode("utf-8")) <= MAX_LOG_BYTES
48
+ assert all(
49
+ secret not in line
50
+ for secret in (
51
+ "request-secret",
52
+ "secret-body",
53
+ "secret-token",
54
+ "secret-error",
55
+ )
56
+ )
57
+
58
+
59
+ def test_unknown_message_and_long_fields_are_bounded() -> None:
60
+ record = logging.makeLogRecord(
61
+ {
62
+ "name": "southwind_agent_server.service",
63
+ "levelno": logging.ERROR,
64
+ "msg": "secret-message",
65
+ "run_id": "x" * 500,
66
+ "report_stage": "payload secret",
67
+ }
68
+ )
69
+ safe = agent_log_record(record)
70
+ assert safe["event"] == "agent.runtime.failed"
71
+ assert "runId" not in safe
72
+ assert "stageKey" not in safe
73
+ assert "secret-message" not in json.dumps(safe)
74
+ assert len(json.dumps(safe).encode("utf-8")) <= MAX_LOG_BYTES
75
+
76
+
77
+ def test_asgi_entrypoint_installs_handler_without_changing_app() -> None:
78
+ from southwind_agent_server.app import app
79
+
80
+ asgi = import_module("southwind_agent_server.asgi")
81
+ configure_agent_logging()
82
+ handlers = [
83
+ handler
84
+ for handler in logging.getLogger("southwind_agent_server").handlers
85
+ if isinstance(handler, AgentStructuredLogHandler)
86
+ ]
87
+ assert asgi.app is app
88
+ assert len(handlers) == 1
89
+
90
+
91
+ def test_stdout_failure_does_not_interrupt_runtime() -> None:
92
+ class BrokenStream:
93
+ def write(self, _value: str) -> None:
94
+ raise OSError("stdout unavailable")
95
+
96
+ def flush(self) -> None:
97
+ raise OSError("stdout unavailable")
98
+
99
+ handler = AgentStructuredLogHandler(cast(TextIO, BrokenStream()))
100
+ record = logging.makeLogRecord(
101
+ {
102
+ "name": "southwind_agent_server.service",
103
+ "levelno": logging.ERROR,
104
+ "msg": "Agent Run 恢复轮询失败,将继续重试",
105
+ }
106
+ )
107
+ handler.emit(record)
108
+
109
+
110
+ def test_bootstrap_removes_borrowed_sink_without_closing_uvicorn(
111
+ monkeypatch: pytest.MonkeyPatch,
112
+ ) -> None:
113
+ stream = io.StringIO()
114
+ legacy_stream = io.StringIO()
115
+ legacy = logging.StreamHandler(legacy_stream)
116
+ structured = AgentStructuredLogHandler(stream)
117
+ logger = logging.getLogger("southwind_agent_server")
118
+ uvicorn = logging.getLogger("uvicorn")
119
+ monkeypatch.setattr(logger, "handlers", [legacy, structured])
120
+ monkeypatch.setattr(logger, "level", logging.INFO)
121
+ monkeypatch.setattr(logger, "propagate", False)
122
+ monkeypatch.setattr(uvicorn, "handlers", [legacy])
123
+ monkeypatch.setattr(uvicorn, "level", logging.INFO)
124
+ monkeypatch.setattr(uvicorn, "propagate", False)
125
+ closed: list[bool] = []
126
+ monkeypatch.setattr(legacy, "close", lambda: closed.append(True))
127
+
128
+ configure_agent_logging()
129
+ configure_agent_logging()
130
+ assert logger.handlers == [structured]
131
+ assert uvicorn.handlers == [legacy]
132
+ assert not closed
133
+ try:
134
+ raise ValueError("secret-exception")
135
+ except ValueError:
136
+ logger.exception("Agent Run 恢复轮询失败,将继续重试")
137
+ assert len(stream.getvalue().splitlines()) == 1
138
+ assert json.loads(stream.getvalue())["errorCode"] == "AGENT_RUN_RECOVERY_FAILED"
139
+ assert legacy_stream.getvalue() == ""
140
+ assert "secret-exception" not in stream.getvalue()
141
+ uvicorn.warning("independent-server-warning")
142
+ assert "independent-server-warning" in legacy_stream.getvalue()