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.
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/CHANGELOG.md +4 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/PKG-INFO +9 -5
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/README.md +6 -3
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/__init__.py +5 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/app.py +451 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/cli.py +33 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/config.py +210 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/env_file.py +157 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/logs.py +128 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/proxy.py +115 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/state.py +216 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +21 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/app.js +712 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/board.css +43 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/bulma.min.css +3 -0
- ms_tau_sdk-1.2.8.dev13/packages/tau-board/src/ms_tau_board/static/index.html +163 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/pyproject.toml +5 -5
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/.gitignore +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/a2a.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/chat.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/dependencies.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/health.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/models.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/api/sessions.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/app.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/application.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/auth.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/client.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/local.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/mcp.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/models.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/backend/routes.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/cli.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/errors.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/logging.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/providers/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/providers/definitions.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/providers/factory.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/resources/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/resources/loader.py +0 -0
- {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
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/events.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/extensions.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/failures.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/manager.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/observability.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/provenance.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/session.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/runtime/task_context.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/sessions/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/sessions/storage.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/settings.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/skills.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/tools/__init__.py +0 -0
- {ms_tau_sdk-1.2.7.dev11 → ms_tau_sdk-1.2.8.dev13}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
- {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.
|
|
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:
|
|
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
|
|
61
|
-
|
|
|
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,
|
|
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
|
|
31
|
-
|
|
|
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,
|
|
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,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())
|