ms-tau-sdk 1.2.7.dev11__tar.gz → 1.2.8.dev13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/CHANGELOG.md +4 -0
  2. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/PKG-INFO +9 -5
  3. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/README.md +6 -3
  4. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/__init__.py +5 -0
  5. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/app.py +451 -0
  6. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/cli.py +33 -0
  7. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/config.py +210 -0
  8. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/env_file.py +157 -0
  9. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/logs.py +128 -0
  10. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/proxy.py +115 -0
  11. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/state.py +216 -0
  12. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +21 -0
  13. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/app.js +712 -0
  14. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/board.css +43 -0
  15. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/bulma.min.css +3 -0
  16. ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/index.html +163 -0
  17. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/pyproject.toml +5 -5
  18. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/.gitignore +0 -0
  19. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/__init__.py +0 -0
  20. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +0 -0
  21. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  22. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +0 -0
  23. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  24. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/__init__.py +0 -0
  25. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/a2a.py +0 -0
  26. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/chat.py +0 -0
  27. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/dependencies.py +0 -0
  28. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/health.py +0 -0
  29. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/models.py +0 -0
  30. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/sessions.py +0 -0
  31. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/app.py +0 -0
  32. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/application.py +0 -0
  33. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/__init__.py +0 -0
  34. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/auth.py +0 -0
  35. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/client.py +0 -0
  36. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/local.py +0 -0
  37. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/mcp.py +0 -0
  38. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/models.py +0 -0
  39. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/routes.py +0 -0
  40. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/cli.py +0 -0
  41. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/errors.py +0 -0
  42. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/logging.py +0 -0
  43. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  44. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  45. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  46. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  47. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  48. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/providers/__init__.py +0 -0
  49. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/providers/definitions.py +0 -0
  50. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/providers/factory.py +0 -0
  51. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  52. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/resources/__init__.py +0 -0
  53. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/resources/loader.py +0 -0
  54. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  55. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  56. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  57. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/events.py +0 -0
  58. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  59. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/failures.py +0 -0
  60. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/manager.py +0 -0
  61. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/observability.py +0 -0
  62. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  63. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/session.py +0 -0
  64. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  65. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  66. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  67. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/sessions/storage.py +0 -0
  68. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/settings.py +0 -0
  69. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/skills.py +0 -0
  70. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/tools/__init__.py +0 -0
  71. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  72. {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## 1.2.7 — 2026-09-21
4
4
 
5
+ - Bundle the local Tau Board command, Python package, and UI assets in the `ms-tau-sdk` wheel.
6
+ `ms-tau-sdk[tau-board]` now selects Board's tested dependency bounds without requiring a
7
+ second PyPI project. The Board remains a separate local process. SDK releases no longer wait
8
+ for an unpublished `ms-tau-board` distribution.
5
9
  - A merge to `main` is the release. The release workflow runs on the merge instead of on a
6
10
  hand-pushed tag: it publishes the version `pyproject.toml` declares, creates the tag `vX.Y.Z` and
7
11
  the GitHub release after the upload, and brings the release merge and the next patch number to
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 1.2.7.dev11
3
+ Version: 1.2.8.dev13
4
4
  Summary: Workspace-bound Tau application primitives for Main Sequence projects
5
5
  Project-URL: Changelog, https://github.com/mainsequence-sdk/ms-tau-sdk/blob/development/CHANGELOG.md
6
6
  Project-URL: Documentation, https://github.com/mainsequence-sdk/ms-tau-sdk/tree/development/docs
@@ -25,7 +25,8 @@ Requires-Dist: structlog<27,>=26.1
25
25
  Requires-Dist: tau-ai==0.4.2
26
26
  Requires-Dist: uvicorn[standard]>=0.35
27
27
  Provides-Extra: tau-board
28
- Requires-Dist: ms-tau-board<0.2,>=0.1; extra == 'tau-board'
28
+ Requires-Dist: starlette<2,>=1.3; extra == 'tau-board'
29
+ Requires-Dist: uvicorn<1,>=0.51; extra == 'tau-board'
29
30
  Description-Content-Type: text/markdown
30
31
 
31
32
  <p align="center">
@@ -57,8 +58,8 @@ deployment model.
57
58
  | Contract | Value |
58
59
  | --- | --- |
59
60
  | PyPI distribution | `ms-tau-sdk` |
60
- | Python package | `ms_tau_sdk` |
61
- | Command | `ms-tau` |
61
+ | Python packages | `ms_tau_sdk`, `ms_tau_board` |
62
+ | Commands | `ms-tau`, `tau-board` |
62
63
  | Python entry point | `ms_tau_sdk.app:create_app` |
63
64
  | Required Python | 3.13 or newer |
64
65
  | Project customization | Standard workspace `.tau/` configuration |
@@ -150,7 +151,7 @@ Local mode also appends privacy-filtered, structured JSON Lines to
150
151
  and log move under `TAU_LOCAL_STATE_ROOT` when set; neither is written to the project `.tau` or
151
152
  uploaded to a platform AgentSession. Console logging remains available.
152
153
 
153
- For a small browser view of a local run, install the optional, separately running
154
+ For a small browser view of a local run, use the separately running
154
155
  [Tau Board](packages/tau-board/README.md):
155
156
 
156
157
  ```bash
@@ -158,6 +159,9 @@ uv add 'ms-tau-sdk[tau-board]'
158
159
  uv run tau-board --tau-url http://127.0.0.1:8010
159
160
  ```
160
161
 
162
+ Tau Board ships in the same SDK wheel. The `tau-board` extra declares its tested dependency
163
+ bounds; plain SDK installations never start the board.
164
+
161
165
  Open `http://127.0.0.1:8788` to chat, send A2A Messages and Tasks, inspect local SQLite state,
162
166
  and read logs. The board serves packaged Bulma CSS with no frontend build or CDN. It can select
163
167
  another loopback Tau endpoint and local state directory in its Connect view. Its Settings tab shows
@@ -27,8 +27,8 @@ deployment model.
27
27
  | Contract | Value |
28
28
  | --- | --- |
29
29
  | PyPI distribution | `ms-tau-sdk` |
30
- | Python package | `ms_tau_sdk` |
31
- | Command | `ms-tau` |
30
+ | Python packages | `ms_tau_sdk`, `ms_tau_board` |
31
+ | Commands | `ms-tau`, `tau-board` |
32
32
  | Python entry point | `ms_tau_sdk.app:create_app` |
33
33
  | Required Python | 3.13 or newer |
34
34
  | Project customization | Standard workspace `.tau/` configuration |
@@ -120,7 +120,7 @@ Local mode also appends privacy-filtered, structured JSON Lines to
120
120
  and log move under `TAU_LOCAL_STATE_ROOT` when set; neither is written to the project `.tau` or
121
121
  uploaded to a platform AgentSession. Console logging remains available.
122
122
 
123
- For a small browser view of a local run, install the optional, separately running
123
+ For a small browser view of a local run, use the separately running
124
124
  [Tau Board](packages/tau-board/README.md):
125
125
 
126
126
  ```bash
@@ -128,6 +128,9 @@ uv add 'ms-tau-sdk[tau-board]'
128
128
  uv run tau-board --tau-url http://127.0.0.1:8010
129
129
  ```
130
130
 
131
+ Tau Board ships in the same SDK wheel. The `tau-board` extra declares its tested dependency
132
+ bounds; plain SDK installations never start the board.
133
+
131
134
  Open `http://127.0.0.1:8788` to chat, send A2A Messages and Tasks, inspect local SQLite state,
132
135
  and read logs. The board serves packaged Bulma CSS with no frontend build or CDN. It can select
133
136
  another loopback Tau endpoint and local state directory in its Connect view. Its Settings tab shows
@@ -0,0 +1,5 @@
1
+ """Local Tau development board, separate from the Tau runtime process."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("ms-tau-sdk")
@@ -0,0 +1,451 @@
1
+ """Standalone loopback application for Tau Board."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import httpx
16
+ from starlette.applications import Starlette
17
+ from starlette.datastructures import MutableHeaders
18
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
19
+ from starlette.requests import Request
20
+ from starlette.responses import FileResponse, JSONResponse, Response
21
+ from starlette.routing import Mount, Route
22
+ from starlette.staticfiles import StaticFiles
23
+
24
+ from . import env_file, logs, state
25
+ from .config import (
26
+ BoardSettings,
27
+ Profile,
28
+ public_environment_value,
29
+ safe_environment_snapshot,
30
+ )
31
+ from .proxy import TauUnavailable, forward, probe
32
+
33
+ STATIC = Path(__file__).parent / "static"
34
+ COOKIE = "tau_board_session"
35
+ SESSION_SECONDS = 24 * 60 * 60
36
+
37
+
38
+ class SecurityHeadersMiddleware:
39
+ def __init__(self, app: Any) -> None:
40
+ self.app = app
41
+
42
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
43
+ async def wrapped_send(message: dict[str, Any]) -> None:
44
+ if message["type"] == "http.response.start":
45
+ headers = MutableHeaders(scope=message)
46
+ headers["Content-Security-Policy"] = (
47
+ "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; "
48
+ "img-src 'self' data:; object-src 'none'; base-uri 'none'; "
49
+ "frame-ancestors 'none'"
50
+ )
51
+ headers["X-Content-Type-Options"] = "nosniff"
52
+ headers["Referrer-Policy"] = "no-referrer"
53
+ await send(message)
54
+
55
+ await self.app(scope, receive, wrapped_send)
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class BrowserSession:
60
+ profiles: dict[str, Profile]
61
+ selected: str
62
+ health: dict[str, dict[str, Any]] = field(default_factory=dict)
63
+ last_used: float = field(default_factory=time.monotonic)
64
+
65
+
66
+ def _session(request: Request) -> tuple[str, BrowserSession, bool]:
67
+ sessions: dict[str, BrowserSession] = request.app.state.sessions
68
+ now = time.monotonic()
69
+ for key in tuple(sessions):
70
+ if now - sessions[key].last_used > SESSION_SECONDS:
71
+ del sessions[key]
72
+ token = request.cookies.get(COOKIE, "")
73
+ if token in sessions:
74
+ session = sessions[token]
75
+ session.last_used = now
76
+ return token, session, False
77
+ if len(sessions) >= 64:
78
+ oldest = min(sessions, key=lambda item: sessions[item].last_used)
79
+ del sessions[oldest]
80
+ token = uuid.uuid4().hex
81
+ profiles = {profile.name: profile for profile in request.app.state.settings.profiles}
82
+ session = BrowserSession(profiles=profiles, selected=next(iter(profiles)))
83
+ sessions[token] = session
84
+ return token, session, True
85
+
86
+
87
+ def _cookie(response: Response, token: str, new: bool) -> Response:
88
+ if new:
89
+ response.set_cookie(
90
+ COOKIE, token, httponly=True, samesite="strict", secure=False, max_age=SESSION_SECONDS
91
+ )
92
+ return response
93
+
94
+
95
+ def _selected(request: Request, session: BrowserSession) -> Profile:
96
+ name = request.headers.get("x-tau-board-profile") or request.query_params.get("profile")
97
+ if name is None:
98
+ name = session.selected
99
+ try:
100
+ return session.profiles[name]
101
+ except KeyError as error:
102
+ raise ValueError("Unknown Tau endpoint profile") from error
103
+
104
+
105
+ def _directory(request: Request, session: BrowserSession, profile: Profile) -> Path:
106
+ settings: BoardSettings = request.app.state.settings
107
+ if profile.state_dir:
108
+ directory = profile.state_dir
109
+ elif settings.state_dir:
110
+ directory = settings.state_dir
111
+ else:
112
+ health = session.health.get(profile.name)
113
+ digest = health.get("workspace_digest") if health else None
114
+ if not isinstance(digest, str) or not digest:
115
+ raise ValueError("Connect to Tau before deriving its local state directory")
116
+ directory = settings.state_root / digest
117
+ return directory
118
+
119
+
120
+ def _state_warning(session: BrowserSession, profile: Profile, directory: Path) -> str | None:
121
+ health = session.health.get(profile.name)
122
+ digest = health.get("workspace_digest") if health else None
123
+ if directory.exists() and digest and directory.name != digest:
124
+ return "Selected state directory differs from Tau's workspace digest"
125
+ return None
126
+
127
+
128
+ def _origin_allowed(request: Request) -> bool:
129
+ origin = request.headers.get("origin")
130
+ if not origin:
131
+ return True
132
+ return origin == f"http://{request.headers.get('host', '')}"
133
+
134
+
135
+ async def index(request: Request) -> Response:
136
+ return FileResponse(STATIC / "index.html")
137
+
138
+
139
+ async def config(request: Request) -> Response:
140
+ token, session, new = _session(request)
141
+ if request.method == "PUT":
142
+ if not _origin_allowed(request):
143
+ response = JSONResponse({"error": "Invalid browser origin"}, status_code=403)
144
+ return _cookie(response, token, new)
145
+ if len(await request.body()) > 20_000:
146
+ response = JSONResponse({"error": "Configuration is too large"}, status_code=413)
147
+ return _cookie(response, token, new)
148
+ value = await request.json()
149
+ if not isinstance(value, dict):
150
+ raise ValueError("Configuration must be an object")
151
+ if "profiles" in value:
152
+ raw_profiles = value["profiles"]
153
+ if not isinstance(raw_profiles, list) or not 1 <= len(raw_profiles) <= 16:
154
+ raise ValueError("Profiles must be a list of 1-16 entries")
155
+ profiles = [Profile.from_dict(item) for item in raw_profiles if isinstance(item, dict)]
156
+ names_are_unique = len({item.name for item in profiles}) == len(profiles)
157
+ if len(profiles) != len(raw_profiles) or not names_are_unique:
158
+ raise ValueError("Profiles must be distinct objects")
159
+ session.profiles = {item.name: item for item in profiles}
160
+ session.health.clear()
161
+ selected = value.get("selectedProfile", session.selected)
162
+ if not isinstance(selected, str) or selected not in session.profiles:
163
+ raise ValueError("Unknown selected profile")
164
+ session.selected = selected
165
+ profile = session.profiles[session.selected]
166
+ try:
167
+ directory = str(_directory(request, session, profile))
168
+ except ValueError:
169
+ directory = str(profile.state_dir or request.app.state.settings.state_dir or "")
170
+ return _cookie(
171
+ JSONResponse(
172
+ {
173
+ "profiles": [item.public() for item in session.profiles.values()],
174
+ "selectedProfile": session.selected,
175
+ "tauUrl": profile.url,
176
+ "stateDir": directory,
177
+ "port": request.app.state.settings.port,
178
+ }
179
+ ),
180
+ token,
181
+ new,
182
+ )
183
+
184
+
185
+ async def connection(request: Request) -> Response:
186
+ token, session, new = _session(request)
187
+ profile = _selected(request, session)
188
+ health = await probe(request.app.state.http, profile)
189
+ session.health[profile.name] = health
190
+ try:
191
+ directory = str(_directory(request, session, profile))
192
+ state_error = _state_warning(session, profile, Path(directory))
193
+ except ValueError as error:
194
+ directory = str(profile.state_dir or request.app.state.settings.state_dir or "")
195
+ state_error = str(error)
196
+ try:
197
+ ready = await request.app.state.http.get(profile.url + "/ready", timeout=5)
198
+ readiness = ready.status_code == 200
199
+ except httpx.HTTPError:
200
+ readiness = False
201
+ return _cookie(
202
+ JSONResponse(
203
+ {
204
+ "profile": profile.public(),
205
+ "health": health,
206
+ "ready": readiness,
207
+ "stateDir": directory,
208
+ "stateError": state_error,
209
+ }
210
+ ),
211
+ token,
212
+ new,
213
+ )
214
+
215
+
216
+ async def settings_view(request: Request) -> Response:
217
+ token, session, new = _session(request)
218
+ env_path: Path = request.app.state.settings.env_file or Path.cwd() / ".env"
219
+ if request.method == "PUT":
220
+ if not _origin_allowed(request):
221
+ return _cookie(
222
+ JSONResponse({"error": "Invalid browser origin"}, status_code=403), token, new
223
+ )
224
+ if len(await request.body()) > 12_000:
225
+ return _cookie(
226
+ JSONResponse({"error": "Settings request is too large"}, status_code=413),
227
+ token,
228
+ new,
229
+ )
230
+ payload = await request.json()
231
+ changes = payload.get("changes") if isinstance(payload, dict) else None
232
+ if not isinstance(changes, dict) or any(
233
+ not isinstance(name, str) or not isinstance(value, str)
234
+ for name, value in changes.items()
235
+ ):
236
+ raise ValueError("Settings changes must be a map of names to strings")
237
+ await asyncio.to_thread(env_file.write_values, env_path, changes)
238
+ file_values = await asyncio.to_thread(env_file.read_values, env_path)
239
+ file_credentials = await asyncio.to_thread(env_file.credential_presence, env_path)
240
+ file_exists = await asyncio.to_thread(env_path.is_file)
241
+ exposed_file_values = {}
242
+ for name, raw in file_values.items():
243
+ value, redacted = public_environment_value(name, raw)
244
+ exposed_file_values[name] = {"value": value, "redacted": redacted}
245
+ profile = session.profiles[session.selected]
246
+ try:
247
+ directory = str(_directory(request, session, profile))
248
+ except ValueError:
249
+ directory = None
250
+ return _cookie(
251
+ JSONResponse(
252
+ {
253
+ **request.app.state.environment_snapshot,
254
+ "envFile": str(env_path),
255
+ "envFileExists": file_exists,
256
+ "envFileValues": exposed_file_values,
257
+ "envFileCredentials": file_credentials,
258
+ "selectedProfile": profile.public(),
259
+ "effectiveStateDir": directory,
260
+ "boardPort": request.app.state.settings.port,
261
+ }
262
+ ),
263
+ token,
264
+ new,
265
+ )
266
+
267
+
268
+ async def state_summary(request: Request) -> Response:
269
+ token, session, new = _session(request)
270
+ profile = _selected(request, session)
271
+ directory = _directory(request, session, profile)
272
+ result = await asyncio.to_thread(state.summary, directory)
273
+ result["warning"] = _state_warning(session, profile, directory)
274
+ return _cookie(JSONResponse(result), token, new)
275
+
276
+
277
+ async def session_list(request: Request) -> Response:
278
+ token, session, new = _session(request)
279
+ directory = _directory(request, session, _selected(request, session))
280
+ result = await asyncio.to_thread(state.sessions, directory)
281
+ return _cookie(JSONResponse(result), token, new)
282
+
283
+
284
+ async def task_list(request: Request) -> Response:
285
+ token, session, new = _session(request)
286
+ directory = _directory(request, session, _selected(request, session))
287
+ result = await asyncio.to_thread(
288
+ state.tasks, directory, session_uid=request.query_params.get("sessionUid", "")
289
+ )
290
+ return _cookie(JSONResponse(result), token, new)
291
+
292
+
293
+ async def task_inspect(request: Request) -> Response:
294
+ token, session, new = _session(request)
295
+ directory = _directory(request, session, _selected(request, session))
296
+ result = await asyncio.to_thread(state.task_detail, directory, request.path_params["task_id"])
297
+ return _cookie(JSONResponse(result), token, new)
298
+
299
+
300
+ async def task_log_records(request: Request) -> Response:
301
+ token, session, new = _session(request)
302
+ directory = _directory(request, session, _selected(request, session))
303
+ detail = await asyncio.to_thread(state.task_detail, directory, request.path_params["task_id"])
304
+ task = detail["task"]
305
+ try:
306
+ offset = int(request.query_params.get("offset", "0"))
307
+ except ValueError as error:
308
+ raise ValueError("Log offset must be an integer") from error
309
+ result = await asyncio.to_thread(
310
+ logs.read_logs,
311
+ directory,
312
+ limit=200,
313
+ offset=offset,
314
+ session=str(task["context_id"]),
315
+ task=str(task["task_id"]),
316
+ task_uid=str(task["uid"]),
317
+ related_session=True,
318
+ since=str(task["created_at"]),
319
+ until=_task_log_end(task),
320
+ )
321
+ return _cookie(JSONResponse(result), token, new)
322
+
323
+
324
+ def _task_log_end(task: dict[str, Any]) -> str:
325
+ if task["status"] not in {"completed", "failed", "canceled", "rejected"}:
326
+ return ""
327
+ return (datetime.fromisoformat(str(task["updated_at"])) + timedelta(seconds=5)).isoformat()
328
+
329
+
330
+ async def state_rows(request: Request) -> Response:
331
+ token, session, new = _session(request)
332
+ directory = _directory(request, session, _selected(request, session))
333
+ try:
334
+ limit = int(request.query_params.get("limit", "50"))
335
+ offset = int(request.query_params.get("offset", "0"))
336
+ except ValueError as error:
337
+ raise ValueError("Pagination must use integers") from error
338
+ result = await asyncio.to_thread(
339
+ state.list_rows, directory, request.path_params["table"], limit=limit, offset=offset
340
+ )
341
+ return _cookie(JSONResponse(result), token, new)
342
+
343
+
344
+ async def state_row(request: Request) -> Response:
345
+ token, session, new = _session(request)
346
+ directory = _directory(request, session, _selected(request, session))
347
+ result = await asyncio.to_thread(
348
+ state.row_detail, directory, request.path_params["table"], request.path_params["rowid"]
349
+ )
350
+ return _cookie(JSONResponse(result), token, new)
351
+
352
+
353
+ async def log_records(request: Request) -> Response:
354
+ token, session, new = _session(request)
355
+ profile = _selected(request, session)
356
+ directory = _directory(request, session, profile)
357
+ try:
358
+ limit = int(request.query_params.get("limit", "100"))
359
+ offset = int(request.query_params.get("offset", "0"))
360
+ except ValueError as error:
361
+ raise ValueError("Log limit must be an integer") from error
362
+ task_id = request.query_params.get("task", "")
363
+ task = None
364
+ if task_id:
365
+ task = (await asyncio.to_thread(state.task_detail, directory, task_id))["task"]
366
+ requested_session = request.query_params.get("session", "")
367
+ if requested_session and requested_session != task["context_id"]:
368
+ raise ValueError("Task does not belong to the selected session")
369
+ result = await asyncio.to_thread(
370
+ logs.read_logs,
371
+ directory,
372
+ limit=limit,
373
+ level=request.query_params.get("level", ""),
374
+ session=str(task["context_id"]) if task else request.query_params.get("session", ""),
375
+ task=task_id,
376
+ task_uid=str(task["uid"]) if task else "",
377
+ related_session=bool(task),
378
+ event=request.query_params.get("event", ""),
379
+ since=str(task["created_at"]) if task else request.query_params.get("since", ""),
380
+ until=_task_log_end(task) if task else "",
381
+ offset=offset,
382
+ )
383
+ result["warning"] = _state_warning(session, profile, directory)
384
+ return _cookie(JSONResponse(result), token, new)
385
+
386
+
387
+ async def tau_proxy(request: Request) -> Response:
388
+ token, session, new = _session(request)
389
+ if request.method != "GET" and not _origin_allowed(request):
390
+ response = JSONResponse({"error": "Invalid browser origin"}, status_code=403)
391
+ return _cookie(response, token, new)
392
+ profile = _selected(request, session)
393
+ result = await forward(request, client=request.app.state.http, profile=profile)
394
+ return _cookie(result, token, new)
395
+
396
+
397
+ async def _error(request: Request, error: Exception) -> Response:
398
+ status = 503 if isinstance(error, TauUnavailable) else 400
399
+ return JSONResponse({"error": str(error)}, status_code=status)
400
+
401
+
402
+ def create_app(
403
+ settings: BoardSettings | None = None,
404
+ *,
405
+ http_client: httpx.AsyncClient | None = None,
406
+ ) -> Starlette:
407
+ """Create the board process's application; never constructs the Tau runtime."""
408
+ resolved = settings or BoardSettings.from_environment()
409
+
410
+ @asynccontextmanager
411
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
412
+ if http_client is not None:
413
+ app.state.http = http_client
414
+ yield
415
+ return
416
+ async with httpx.AsyncClient(
417
+ timeout=httpx.Timeout(connect=5, read=None, write=60, pool=5),
418
+ trust_env=False,
419
+ follow_redirects=False,
420
+ ) as client:
421
+ app.state.http = client
422
+ yield
423
+
424
+ app = Starlette(
425
+ lifespan=lifespan,
426
+ routes=[
427
+ Route("/", index),
428
+ Route("/api/board/config", config, methods=["GET", "PUT"]),
429
+ Route("/api/board/connection", connection),
430
+ Route("/api/board/settings", settings_view, methods=["GET", "PUT"]),
431
+ Route("/api/board/state", state_summary),
432
+ Route("/api/board/state/{table}/{rowid:int}", state_row),
433
+ Route("/api/board/state/{table}", state_rows),
434
+ Route("/api/board/logs", log_records),
435
+ Route("/api/board/sessions", session_list),
436
+ Route("/api/board/tasks", task_list),
437
+ Route("/api/board/tasks/{task_id}/logs", task_log_records),
438
+ Route("/api/board/tasks/{task_id}", task_inspect),
439
+ Route("/tau/{path:path}", tau_proxy, methods=["GET", "POST", "PUT"]),
440
+ Mount("/assets", StaticFiles(directory=STATIC)),
441
+ ],
442
+ exception_handlers={ValueError: _error, state.StateError: _error, TauUnavailable: _error},
443
+ )
444
+ app.state.settings = resolved
445
+ app.state.environment_snapshot = safe_environment_snapshot()
446
+ app.state.sessions = {}
447
+
448
+ app.add_middleware(SecurityHeadersMiddleware)
449
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=["127.0.0.1", "localhost"])
450
+
451
+ return app
@@ -0,0 +1,33 @@
1
+ """Launch the board without importing or starting the Tau runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+
8
+ import uvicorn
9
+
10
+ from .app import create_app
11
+ from .config import BoardSettings
12
+
13
+
14
+ def main(argv: Sequence[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(prog="tau-board", description="Local Tau development board")
16
+ parser.add_argument("--tau-url", help="Loopback URL of an existing Tau process")
17
+ parser.add_argument("--state-dir", help="Directory containing runtime.sqlite3 and logs/")
18
+ parser.add_argument("--port", type=int, help="Board port (default: 8788)")
19
+ args = parser.parse_args(argv)
20
+ try:
21
+ settings = BoardSettings.from_environment(
22
+ tau_url=args.tau_url,
23
+ state_dir=args.state_dir,
24
+ port=args.port,
25
+ )
26
+ except ValueError as error:
27
+ parser.error(str(error))
28
+ uvicorn.run(create_app(settings), host="127.0.0.1", port=settings.port, access_log=False)
29
+ return 0
30
+
31
+
32
+ if __name__ == "__main__":
33
+ raise SystemExit(main())