valved 0.1.0__py3-none-any.whl

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 (333) hide show
  1. valved/__init__.py +5 -0
  2. valved/__main__.py +6 -0
  3. valved/api/__init__.py +11 -0
  4. valved/api/auth.py +141 -0
  5. valved/api/dependencies.py +70 -0
  6. valved/api/errors.py +333 -0
  7. valved/api/idempotency.py +174 -0
  8. valved/api/main.py +120 -0
  9. valved/api/openapi_meta.py +76 -0
  10. valved/api/pagination.py +159 -0
  11. valved/api/routers/__init__.py +6 -0
  12. valved/api/routers/agents.py +81 -0
  13. valved/api/routers/asks.py +206 -0
  14. valved/api/routers/builds.py +142 -0
  15. valved/api/routers/components.py +62 -0
  16. valved/api/routers/deploys.py +234 -0
  17. valved/api/routers/health.py +78 -0
  18. valved/api/routers/investigations.py +147 -0
  19. valved/api/routers/jobs.py +73 -0
  20. valved/api/routers/mcp_servers.py +72 -0
  21. valved/api/routers/memory.py +138 -0
  22. valved/api/routers/metrics.py +104 -0
  23. valved/api/routers/pipelines.py +92 -0
  24. valved/api/routers/plans.py +136 -0
  25. valved/api/routers/runs.py +271 -0
  26. valved/api/routers/schedules.py +152 -0
  27. valved/api/routers/skills.py +71 -0
  28. valved/api/routers/targets.py +74 -0
  29. valved/api/routers/tokens.py +79 -0
  30. valved/api/routers/webhooks.py +112 -0
  31. valved/api/routers/workers.py +54 -0
  32. valved/api/schemas.py +46 -0
  33. valved/api/streams.py +178 -0
  34. valved/api/webhooks.py +265 -0
  35. valved/cli/__init__.py +1 -0
  36. valved/cli/commands/__init__.py +6 -0
  37. valved/cli/commands/agents.py +264 -0
  38. valved/cli/commands/ask.py +266 -0
  39. valved/cli/commands/asks.py +166 -0
  40. valved/cli/commands/auth.py +133 -0
  41. valved/cli/commands/build.py +355 -0
  42. valved/cli/commands/component/__init__.py +421 -0
  43. valved/cli/commands/component/graduation.py +205 -0
  44. valved/cli/commands/connect.py +236 -0
  45. valved/cli/commands/deploy.py +247 -0
  46. valved/cli/commands/docs.py +256 -0
  47. valved/cli/commands/el/__init__.py +56 -0
  48. valved/cli/commands/el/list.py +217 -0
  49. valved/cli/commands/el/run.py +361 -0
  50. valved/cli/commands/el/verify.py +185 -0
  51. valved/cli/commands/init.py +296 -0
  52. valved/cli/commands/investigations.py +175 -0
  53. valved/cli/commands/logs.py +45 -0
  54. valved/cli/commands/mcp_serve.py +169 -0
  55. valved/cli/commands/mcp_servers.py +119 -0
  56. valved/cli/commands/memory.py +308 -0
  57. valved/cli/commands/metrics/__init__.py +32 -0
  58. valved/cli/commands/metrics/commands.py +191 -0
  59. valved/cli/commands/packaging.py +132 -0
  60. valved/cli/commands/pipelines.py +321 -0
  61. valved/cli/commands/plan.py +280 -0
  62. valved/cli/commands/plan_and_build.py +171 -0
  63. valved/cli/commands/run.py +173 -0
  64. valved/cli/commands/runs.py +69 -0
  65. valved/cli/commands/schedule/__init__.py +59 -0
  66. valved/cli/commands/schedule/commands.py +249 -0
  67. valved/cli/commands/serve.py +501 -0
  68. valved/cli/commands/skills.py +149 -0
  69. valved/cli/commands/target/__init__.py +23 -0
  70. valved/cli/commands/target/create.py +62 -0
  71. valved/cli/commands/target/delete.py +108 -0
  72. valved/cli/commands/target/list.py +76 -0
  73. valved/cli/commands/target/rename.py +93 -0
  74. valved/cli/commands/target/show.py +94 -0
  75. valved/cli/commands/version.py +14 -0
  76. valved/cli/commands/worker.py +196 -0
  77. valved/cli/dotenv.py +111 -0
  78. valved/cli/main.py +130 -0
  79. valved/cli/orchestrator/__init__.py +52 -0
  80. valved/cli/orchestrator/builder.py +1463 -0
  81. valved/cli/orchestrator/cost_rollup.py +179 -0
  82. valved/cli/orchestrator/delegation_run.py +507 -0
  83. valved/cli/orchestrator/extensibility_wiring.py +328 -0
  84. valved/cli/orchestrator/extra_tools.py +179 -0
  85. valved/cli/orchestrator/failure_taxonomy.py +150 -0
  86. valved/cli/orchestrator/goal_classifier.py +212 -0
  87. valved/cli/orchestrator/goal_decomposer.py +270 -0
  88. valved/cli/orchestrator/listing.py +381 -0
  89. valved/cli/orchestrator/observers.py +223 -0
  90. valved/cli/orchestrator/planner.py +1235 -0
  91. valved/cli/orchestrator/recovery.py +343 -0
  92. valved/cli/orchestrator/recovery_trigger.py +321 -0
  93. valved/cli/orchestrator/review_wiring.py +293 -0
  94. valved/cli/orchestrator/runner.py +606 -0
  95. valved/core/__init__.py +6 -0
  96. valved/core/agents/__init__.py +103 -0
  97. valved/core/agents/builtin/README.md +19 -0
  98. valved/core/agents/builtin/dbt-engineer.md +188 -0
  99. valved/core/agents/builtin/dbt-qa.md +83 -0
  100. valved/core/agents/builtin/dlt-engineer.md +208 -0
  101. valved/core/agents/builtin/dlt-qa.md +85 -0
  102. valved/core/agents/builtin/dlt-security.md +80 -0
  103. valved/core/agents/builtin/explorer.md +221 -0
  104. valved/core/agents/builtin/pipeline-engineer.md +210 -0
  105. valved/core/agents/builtin/sql-specialist.md +47 -0
  106. valved/core/agents/cancel.py +57 -0
  107. valved/core/agents/client_factory.py +261 -0
  108. valved/core/agents/compaction.py +77 -0
  109. valved/core/agents/delegation.py +309 -0
  110. valved/core/agents/discovery.py +232 -0
  111. valved/core/agents/exceptions.py +30 -0
  112. valved/core/agents/lint.py +77 -0
  113. valved/core/agents/loader.py +325 -0
  114. valved/core/agents/loop.py +659 -0
  115. valved/core/agents/m1_tools.py +555 -0
  116. valved/core/agents/observer.py +123 -0
  117. valved/core/agents/orchestrator_hooks/__init__.py +14 -0
  118. valved/core/agents/orchestrator_hooks/memory.py +60 -0
  119. valved/core/agents/permissions/__init__.py +73 -0
  120. valved/core/agents/permissions/bash_gate.py +207 -0
  121. valved/core/agents/permissions/gate.py +237 -0
  122. valved/core/agents/permissions/modes.py +107 -0
  123. valved/core/agents/permissions/policy.py +817 -0
  124. valved/core/agents/permissions/warehouse_roles.py +70 -0
  125. valved/core/agents/pricing.py +113 -0
  126. valved/core/agents/prompts/m1_build_agent.md +109 -0
  127. valved/core/agents/prompts/m1_plan_agent.md +108 -0
  128. valved/core/agents/prompts/recovery_el_run_agent.md +69 -0
  129. valved/core/agents/recovery/__init__.py +82 -0
  130. valved/core/agents/recovery/agent.py +601 -0
  131. valved/core/agents/recovery/classify.py +247 -0
  132. valved/core/agents/recovery/invocation.py +97 -0
  133. valved/core/agents/recovery/resolve.py +48 -0
  134. valved/core/agents/recovery/schema_diff.py +150 -0
  135. valved/core/agents/recovery/trigger.py +419 -0
  136. valved/core/agents/review_fan_out.py +229 -0
  137. valved/core/agents/routing.py +81 -0
  138. valved/core/agents/run_explorer.py +508 -0
  139. valved/core/agents/steering.py +52 -0
  140. valved/core/agents/subagent_registry.py +258 -0
  141. valved/core/agents/tool_binding.py +113 -0
  142. valved/core/agents/tools/__init__.py +74 -0
  143. valved/core/agents/tools/bash_tool.py +282 -0
  144. valved/core/agents/tools/fs_tools.py +273 -0
  145. valved/core/agents/tools/search_tools.py +202 -0
  146. valved/core/agents/tools/secrets_denylist.py +75 -0
  147. valved/core/agents/tools/submit_result.py +125 -0
  148. valved/core/agents/tools/todo_tool.py +107 -0
  149. valved/core/agents/tools/web_tools.py +119 -0
  150. valved/core/agents/verification.py +175 -0
  151. valved/core/asks/__init__.py +35 -0
  152. valved/core/asks/citation_builder.py +236 -0
  153. valved/core/asks/models.py +134 -0
  154. valved/core/config/__init__.py +80 -0
  155. valved/core/config/exceptions.py +48 -0
  156. valved/core/config/loader.py +248 -0
  157. valved/core/config/paths.py +74 -0
  158. valved/core/config/pipeline_schema.py +590 -0
  159. valved/core/config/schema.py +755 -0
  160. valved/core/config/state_store.py +77 -0
  161. valved/core/connect/__init__.py +54 -0
  162. valved/core/connect/dbt_provision.py +198 -0
  163. valved/core/connect/installer.py +221 -0
  164. valved/core/connect/result.py +126 -0
  165. valved/core/connectors/__init__.py +25 -0
  166. valved/core/connectors/duckdb.py +65 -0
  167. valved/core/connectors/exceptions.py +41 -0
  168. valved/core/connectors/snowflake.py +311 -0
  169. valved/core/dbt_execution/__init__.py +60 -0
  170. valved/core/dbt_execution/backend.py +129 -0
  171. valved/core/dbt_execution/engine.py +119 -0
  172. valved/core/dbt_execution/local.py +159 -0
  173. valved/core/dbt_execution/result.py +136 -0
  174. valved/core/dbt_execution/verify_bridge.py +235 -0
  175. valved/core/deploy/__init__.py +70 -0
  176. valved/core/deploy/cross_repo.py +691 -0
  177. valved/core/deploy/ddl_applier.py +304 -0
  178. valved/core/deploy/git_promote.py +711 -0
  179. valved/core/deploy/identifiers.py +60 -0
  180. valved/core/deploy/preflight.py +450 -0
  181. valved/core/deploy/recovery.py +143 -0
  182. valved/core/deploy/templates/commit_message.j2 +11 -0
  183. valved/core/deploy/templates/pr_body.md.j2 +36 -0
  184. valved/core/deploy/verifier.py +180 -0
  185. valved/core/hooks/__init__.py +55 -0
  186. valved/core/hooks/config.py +146 -0
  187. valved/core/hooks/events.py +130 -0
  188. valved/core/hooks/runner.py +124 -0
  189. valved/core/hooks/wiring.py +201 -0
  190. valved/core/mcp/__init__.py +46 -0
  191. valved/core/mcp/client.py +127 -0
  192. valved/core/mcp/config.py +217 -0
  193. valved/core/memory/__init__.py +31 -0
  194. valved/core/memory/loader.py +125 -0
  195. valved/core/memory/selector.py +72 -0
  196. valved/core/memory/writer.py +196 -0
  197. valved/core/observability/__init__.py +36 -0
  198. valved/core/observability/recording.py +207 -0
  199. valved/core/observability/rollups.py +272 -0
  200. valved/core/redaction.py +148 -0
  201. valved/core/runners/__init__.py +21 -0
  202. valved/core/runners/base.py +69 -0
  203. valved/core/runners/local_venv.py +472 -0
  204. valved/core/runners/subprocess.py +126 -0
  205. valved/core/skills/__init__.py +46 -0
  206. valved/core/skills/builtin/__init__.py +58 -0
  207. valved/core/skills/builtin/catalog.py +268 -0
  208. valved/core/skills/builtin/dlt_strategies/curated_library/SKILL.md +65 -0
  209. valved/core/skills/builtin/dlt_strategies/native_dlt/SKILL.md +65 -0
  210. valved/core/skills/builtin/dlt_strategies/rest_api_config/SKILL.md +62 -0
  211. valved/core/skills/builtin/dlt_strategies/singer_wrapper/SKILL.md +57 -0
  212. valved/core/skills/context.py +71 -0
  213. valved/core/skills/decorator.py +80 -0
  214. valved/core/skills/executor.py +68 -0
  215. valved/core/skills/pack_discovery.py +220 -0
  216. valved/core/skills/packs.py +144 -0
  217. valved/core/skills/registry.py +153 -0
  218. valved/core/skills/result.py +30 -0
  219. valved/core/sql/__init__.py +40 -0
  220. valved/core/sql/classify.py +131 -0
  221. valved/core/sql/dialects.py +90 -0
  222. valved/core/sql/introspect.py +204 -0
  223. valved/core/sql/tool.py +181 -0
  224. valved/core/state/__init__.py +82 -0
  225. valved/core/state/asks.py +173 -0
  226. valved/core/state/database.py +133 -0
  227. valved/core/state/deploys.py +160 -0
  228. valved/core/state/events_read.py +107 -0
  229. valved/core/state/idempotency.py +116 -0
  230. valved/core/state/investigations.py +220 -0
  231. valved/core/state/job_queue.py +755 -0
  232. valved/core/state/models.py +1069 -0
  233. valved/core/state/repository.py +623 -0
  234. valved/core/state/schedules.py +680 -0
  235. valved/core/state/store.py +80 -0
  236. valved/core/state/telemetry.py +210 -0
  237. valved/core/state/tokens.py +215 -0
  238. valved/core/state/webhooks.py +303 -0
  239. valved/core/steps/__init__.py +23 -0
  240. valved/core/steps/base.py +94 -0
  241. valved/core/steps/python.py +71 -0
  242. valved/core/targets/__init__.py +60 -0
  243. valved/core/targets/destination.py +293 -0
  244. valved/core/targets/names.py +59 -0
  245. valved/core/targets/registry.py +465 -0
  246. valved/core/targets/resolution.py +106 -0
  247. valved/init/__init__.py +36 -0
  248. valved/init/detect.py +85 -0
  249. valved/init/plan.py +201 -0
  250. valved/init/scaffold.py +93 -0
  251. valved/init/templates.py +340 -0
  252. valved/integrations/__init__.py +50 -0
  253. valved/integrations/component_locator.py +329 -0
  254. valved/integrations/dbt/__init__.py +48 -0
  255. valved/integrations/dbt/conventions.py +779 -0
  256. valved/integrations/dbt/manifest.py +350 -0
  257. valved/integrations/dbt/runner.py +134 -0
  258. valved/integrations/dbt/sources.py +197 -0
  259. valved/integrations/dbt/verify.py +263 -0
  260. valved/integrations/dlt/__init__.py +46 -0
  261. valved/integrations/dlt/code_emitter.py +83 -0
  262. valved/integrations/dlt/library.py +401 -0
  263. valved/integrations/dlt/runner.py +143 -0
  264. valved/integrations/dlt/schema_reader.py +624 -0
  265. valved/integrations/dlt/skills.py +225 -0
  266. valved/integrations/dlt/verify.py +210 -0
  267. valved/integrations/provenance.py +176 -0
  268. valved/integrations/workspace_cache.py +299 -0
  269. valved/mcp/__init__.py +32 -0
  270. valved/mcp/adapter.py +151 -0
  271. valved/mcp/auth.py +58 -0
  272. valved/mcp/server.py +229 -0
  273. valved/mcp/tool_generator.py +263 -0
  274. valved/py.typed +0 -0
  275. valved/runtime/__init__.py +91 -0
  276. valved/runtime/archiver.py +374 -0
  277. valved/runtime/clock.py +114 -0
  278. valved/runtime/component_resolution.py +93 -0
  279. valved/runtime/cron.py +127 -0
  280. valved/runtime/events.py +99 -0
  281. valved/runtime/execute_pipeline.py +345 -0
  282. valved/runtime/failure_modes.py +140 -0
  283. valved/runtime/heartbeat.py +127 -0
  284. valved/runtime/jinja_context.py +143 -0
  285. valved/runtime/persisting_step_sink.py +136 -0
  286. valved/runtime/pipeline_dag.py +171 -0
  287. valved/runtime/pipeline_verify.py +240 -0
  288. valved/runtime/reaper.py +145 -0
  289. valved/runtime/run_context.py +45 -0
  290. valved/runtime/scheduler.py +190 -0
  291. valved/runtime/skills/__init__.py +75 -0
  292. valved/runtime/skills/list_components.py +96 -0
  293. valved/runtime/skills/list_dbt_models.py +82 -0
  294. valved/runtime/skills/pipeline_inspect.py +175 -0
  295. valved/runtime/step_executor.py +142 -0
  296. valved/runtime/step_types/__init__.py +33 -0
  297. valved/runtime/step_types/connections.py +132 -0
  298. valved/runtime/step_types/dbt.py +245 -0
  299. valved/runtime/step_types/dlt.py +517 -0
  300. valved/runtime/step_types/registry.py +110 -0
  301. valved/runtime/step_types/sql.py +354 -0
  302. valved/runtime/worker.py +559 -0
  303. valved/runtime/worker_pool.py +196 -0
  304. valved/sources/__init__.py +17 -0
  305. valved/sources/_reference_hackernews/SKILL.md +77 -0
  306. valved/sources/_reference_hackernews/scripts/__init__.py +62 -0
  307. valved/sources/_reference_hackernews/scripts/introspect.py +48 -0
  308. valved/sources/_reference_hackernews/scripts/requirements.txt +3 -0
  309. valved/ui/__init__.py +16 -0
  310. valved/ui/markdown.py +48 -0
  311. valved/ui/queries.py +985 -0
  312. valved/ui/render.py +376 -0
  313. valved/ui/server.py +119 -0
  314. valved/ui/static/favicon.svg +4 -0
  315. valved/ui/static/valved.css +209 -0
  316. valved/ui/static/valved.js +54 -0
  317. valved/ui/templates/_macros.html.j2 +97 -0
  318. valved/ui/templates/agents.html.j2 +47 -0
  319. valved/ui/templates/base.html.j2 +37 -0
  320. valved/ui/templates/index.html.j2 +88 -0
  321. valved/ui/templates/pipeline_detail.html.j2 +61 -0
  322. valved/ui/templates/pipelines.html.j2 +39 -0
  323. valved/ui/templates/run_detail.html.j2 +82 -0
  324. valved/ui/templates/runs.html.j2 +14 -0
  325. valved/ui/templates/skills.html.j2 +74 -0
  326. valved/ui/triggers.py +202 -0
  327. valved/ui/urls.py +184 -0
  328. valved/version.py +14 -0
  329. valved-0.1.0.dist-info/METADATA +130 -0
  330. valved-0.1.0.dist-info/RECORD +333 -0
  331. valved-0.1.0.dist-info/WHEEL +4 -0
  332. valved-0.1.0.dist-info/entry_points.txt +2 -0
  333. valved-0.1.0.dist-info/licenses/LICENSE +201 -0
valved/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Valved — AI-first data engineering framework."""
2
+
3
+ from valved.version import __version__
4
+
5
+ __all__ = ["__version__"]
valved/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m valved`."""
2
+
3
+ from valved.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
valved/api/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Valved's REST API — a FastAPI app over the shipped services.
2
+
3
+ ``create_app(state_store, config)`` (in :mod:`valved.api.main`) builds the app
4
+ `valved serve` runs: bearer auth + idempotency middleware, problem+json errors,
5
+ cursor pagination, the ``/api/v1`` router tree, run streaming (WebSocket/SSE),
6
+ and the webhook publisher loop. See the rest-api capability spec.
7
+ """
8
+
9
+ from valved.api.main import create_app
10
+
11
+ __all__ = ["create_app"]
valved/api/auth.py ADDED
@@ -0,0 +1,141 @@
1
+ """Bearer-token authentication for the REST API.
2
+
3
+ :class:`AuthMiddleware` gates every ``/api/v1/*`` request: it extracts the
4
+ ``Authorization: Bearer <token>`` header, resolves it to an :class:`Identity` via
5
+ ``state_store.tokens.find_by_token`` (argon2 verification against the active
6
+ tokens), stashes the identity on ``request.state``, and stamps ``last_used_at``.
7
+ Missing/invalid tokens → 401 problem+json.
8
+
9
+ ``Identity`` and the argon2 hashing / token-generation helpers are defined in the
10
+ state layer (:mod:`valved.core.state.tokens`) so ``Tokens.create`` is
11
+ self-contained and ``api`` depends on ``state`` (never the reverse); they are
12
+ re-exported here as the module's public auth surface. This module also owns the
13
+ OSS default-token bootstrap (:func:`ensure_default_token`) and rotation
14
+ (:func:`rotate_token`), which write the plaintext to ``.valved/token``.
15
+
16
+ Health/OpenAPI/docs routes (outside ``/api/v1``) are unauthenticated plumbing.
17
+ WebSocket upgrades bypass ``BaseHTTPMiddleware`` entirely (Starlette only runs it
18
+ for ``http`` scopes), so the stream handler authenticates at connection time.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ import stat
26
+ from typing import TYPE_CHECKING
27
+
28
+ from starlette.concurrency import run_in_threadpool
29
+ from starlette.middleware.base import BaseHTTPMiddleware
30
+
31
+ from valved.api.errors import problem
32
+
33
+ # Re-exported public auth surface (defined in the state layer — see module docs).
34
+ from valved.core.state.tokens import (
35
+ Identity,
36
+ generate_token,
37
+ hash_token,
38
+ verify_token,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from pathlib import Path
43
+
44
+ from starlette.requests import Request
45
+ from starlette.types import ASGIApp
46
+
47
+ from valved.core.state.store import StateStore
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ #: Everything under this prefix requires a valid bearer token.
52
+ _PROTECTED_PREFIX = "/api/v1"
53
+
54
+
55
+ class AuthMiddleware(BaseHTTPMiddleware):
56
+ """Reject unauthenticated ``/api/v1/*`` requests; attach the identity otherwise."""
57
+
58
+ def __init__(self, app: ASGIApp, *, state_store: StateStore) -> None:
59
+ super().__init__(app)
60
+ self._state_store = state_store
61
+
62
+ async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
63
+ path = request.url.path
64
+ if not path.startswith(_PROTECTED_PREFIX):
65
+ # Health / OpenAPI / docs are unauthenticated plumbing.
66
+ return await call_next(request)
67
+
68
+ auth_header = request.headers.get("authorization", "")
69
+ if not auth_header.startswith("Bearer "):
70
+ return problem(
71
+ 401,
72
+ "missing-bearer-token",
73
+ "Missing bearer token",
74
+ detail="Provide an `Authorization: Bearer <token>` header.",
75
+ instance=path,
76
+ )
77
+ token_plain = auth_header[len("Bearer ") :].strip()
78
+ identity = await run_in_threadpool(self._state_store.tokens.find_by_token, token_plain)
79
+ if identity is None:
80
+ return problem(
81
+ 401,
82
+ "invalid-token",
83
+ "Invalid or revoked token",
84
+ detail="The bearer token was not recognized.",
85
+ instance=path,
86
+ )
87
+ request.state.identity = identity
88
+ # Best-effort last-used stamp; a write hiccup must not fail the request.
89
+ try:
90
+ await run_in_threadpool(self._state_store.tokens.touch_last_used, identity.token_id)
91
+ except Exception: # pragma: no cover - defensive
92
+ logger.warning("failed to stamp token last_used_at", exc_info=True)
93
+ return await call_next(request)
94
+
95
+
96
+ def _write_token_file(token_path: Path, plaintext: str) -> None:
97
+ """Write the plaintext token to ``token_path`` with 0600 perms (owner-only)."""
98
+ token_path.parent.mkdir(parents=True, exist_ok=True)
99
+ token_path.write_text(plaintext + "\n")
100
+ try:
101
+ os.chmod(token_path, stat.S_IRUSR | stat.S_IWUSR)
102
+ except OSError: # pragma: no cover - platform-dependent (e.g. Windows)
103
+ pass
104
+
105
+
106
+ def ensure_default_token(state_store: StateStore, token_path: Path) -> str | None:
107
+ """Mint the OSS default token (scope ``["*"]``) iff none exists yet.
108
+
109
+ Idempotent: with an active default token already present it returns ``None``
110
+ and leaves ``.valved/token`` untouched. Otherwise it mints one, writes the
111
+ plaintext to ``token_path`` (0600), and returns it. Requires the ``tokens``
112
+ table (migrations at head) — call it from ``valved serve`` startup after
113
+ migrations, and best-effort at ``valved init`` when the DB is reachable.
114
+ """
115
+ if state_store.tokens.has_active_default():
116
+ return None
117
+ _token_id, plaintext = state_store.tokens.create(scopes=["*"], is_default=True)
118
+ _write_token_file(token_path, plaintext)
119
+ return plaintext
120
+
121
+
122
+ def rotate_token(state_store: StateStore, token_path: Path) -> str:
123
+ """Mint a fresh full-scope token, rewrite ``.valved/token``, and return it.
124
+
125
+ Does not revoke prior tokens (the user may hold others); ``valved auth rotate``
126
+ prints the returned plaintext.
127
+ """
128
+ _token_id, plaintext = state_store.tokens.create(scopes=["*"])
129
+ _write_token_file(token_path, plaintext)
130
+ return plaintext
131
+
132
+
133
+ __all__ = [
134
+ "AuthMiddleware",
135
+ "Identity",
136
+ "ensure_default_token",
137
+ "generate_token",
138
+ "hash_token",
139
+ "rotate_token",
140
+ "verify_token",
141
+ ]
@@ -0,0 +1,70 @@
1
+ """Shared FastAPI dependencies for the routers.
2
+
3
+ The app stashes the :class:`StateStore` and :class:`Config` on ``app.state`` at
4
+ :func:`valved.api.main.create_app` time; these accessors read them back.
5
+ :func:`get_identity` returns the authenticated principal the
6
+ :class:`valved.api.auth.AuthMiddleware` attached to ``request.state``.
7
+ :func:`pagination_params` parses + clamps the ``?cursor=&limit=`` query pair.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from fastapi import Query, Request
15
+
16
+ from valved.api.errors import Unauthorized
17
+ from valved.api.pagination import PageParams, make_page_params
18
+
19
+ if TYPE_CHECKING:
20
+ from valved.core.config import Config
21
+ from valved.core.config.paths import ProjectPaths
22
+ from valved.core.state.store import StateStore
23
+ from valved.core.state.tokens import Identity
24
+
25
+
26
+ def get_state_store(request: Request) -> StateStore:
27
+ """The process-wide :class:`StateStore` bundle."""
28
+ return request.app.state.state_store # type: ignore[no-any-return]
29
+
30
+
31
+ def get_config(request: Request) -> Config:
32
+ """The merged :class:`Config`."""
33
+ return request.app.state.config # type: ignore[no-any-return]
34
+
35
+
36
+ def get_project_paths(request: Request) -> ProjectPaths:
37
+ """The :class:`ProjectPaths` (filesystem roots for agents/skills/memory)."""
38
+ return request.app.state.project_paths # type: ignore[no-any-return]
39
+
40
+
41
+ def get_identity(request: Request) -> Identity:
42
+ """The authenticated principal (set by ``AuthMiddleware``).
43
+
44
+ NOTE(rest-api): see issue — hosted-forward per-tenant authorization / scope
45
+ enforcement (``scopes``) belongs here; OSS is single-user full-scope.
46
+ """
47
+ identity = getattr(request.state, "identity", None)
48
+ if identity is None: # pragma: no cover - middleware guarantees it under /api/v1
49
+ raise Unauthorized("Authentication required.")
50
+ return identity # type: ignore[no-any-return]
51
+
52
+
53
+ def pagination_params(
54
+ cursor: str | None = Query(default=None, description="Opaque pagination cursor."),
55
+ limit: int | None = Query(
56
+ default=None,
57
+ description="Max items per page (default 50, max 200).",
58
+ ),
59
+ ) -> PageParams:
60
+ """Parse + clamp the pagination query pair."""
61
+ return make_page_params(cursor, limit)
62
+
63
+
64
+ __all__ = [
65
+ "get_config",
66
+ "get_identity",
67
+ "get_project_paths",
68
+ "get_state_store",
69
+ "pagination_params",
70
+ ]
valved/api/errors.py ADDED
@@ -0,0 +1,333 @@
1
+ """RFC 9457 ``application/problem+json`` error handling for the REST API.
2
+
3
+ Two responsibilities:
4
+
5
+ * :func:`problem` — build a problem+json ``JSONResponse`` with a stable
6
+ ``https://valved.dev/errors/<slug>`` ``type`` URL, ``title``, ``status``,
7
+ optional ``detail``/``instance``, and arbitrary custom fields.
8
+ * :class:`ProblemJsonExceptionHandler` + :func:`install_error_handlers` — turn
9
+ the codebase's *scattered* domain exceptions (there is **no** unified
10
+ ``ValvedError`` base) into problem+json.
11
+
12
+ **Design decision (mapping table, not a base-class retrofit).** Rather than
13
+ introduce a ``ValvedError`` base and retrofit every module in this slice, the
14
+ handler keys an explicit ``exception → (type-slug, status, title)`` table by the
15
+ exception's *fully-qualified class name* (walking the MRO). That means this
16
+ module imports **none** of the heavy orchestrator/agent modules the exceptions
17
+ live in — it matches by name and reads attributes with ``getattr``. An
18
+ unrecognized exception becomes 500 ``.../errors/internal`` with the stack trace
19
+ logged (never returned to the client). API-local signalling exceptions
20
+ (:class:`ResourceNotFound`/:class:`BadRequest`/:class:`Conflict`) carry their own
21
+ status/type and are handled by ``isinstance`` (same module, no import cost).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ from fastapi.exceptions import RequestValidationError
31
+ from starlette.exceptions import HTTPException as StarletteHTTPException
32
+ from starlette.responses import JSONResponse
33
+
34
+ if TYPE_CHECKING:
35
+ from fastapi import FastAPI
36
+ from starlette.requests import Request
37
+ from starlette.responses import Response
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ #: Every problem ``type`` is this base + a stable slug. Documented + versioned;
42
+ #: clients switch on the slug, never on ``title``/``detail``.
43
+ _ERROR_BASE = "https://valved.dev/errors/"
44
+
45
+ PROBLEM_JSON_MEDIA_TYPE = "application/problem+json"
46
+
47
+
48
+ def problem(
49
+ status: int,
50
+ type_slug: str,
51
+ title: str,
52
+ *,
53
+ detail: str | None = None,
54
+ instance: str | None = None,
55
+ **extra: Any,
56
+ ) -> JSONResponse:
57
+ """Build an RFC 9457 problem+json response.
58
+
59
+ ``type`` is ``_ERROR_BASE + type_slug``. ``detail``/``instance`` and any
60
+ non-``None`` ``extra`` custom fields (e.g. ``plan_id``,
61
+ ``expected_config_hash``) are merged into the body.
62
+ """
63
+ body: dict[str, Any] = {
64
+ "type": _ERROR_BASE + type_slug,
65
+ "title": title,
66
+ "status": status,
67
+ }
68
+ if detail is not None:
69
+ body["detail"] = detail
70
+ if instance is not None:
71
+ body["instance"] = instance
72
+ for key, value in extra.items():
73
+ if value is not None:
74
+ body[key] = value
75
+ return JSONResponse(status_code=status, content=body, media_type=PROBLEM_JSON_MEDIA_TYPE)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # API-local signalling exceptions (raised by routers)
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class ApiError(Exception):
84
+ """Base for router-raised problems that carry their own status/type/title.
85
+
86
+ Subclasses set the class attributes; the instance carries an optional
87
+ ``detail`` and arbitrary problem-body ``extra`` fields.
88
+ """
89
+
90
+ status: int = 500
91
+ type_slug: str = "internal"
92
+ title: str = "Internal server error"
93
+
94
+ def __init__(self, detail: str | None = None, **extra: Any) -> None:
95
+ self.detail = detail
96
+ self.extra = extra
97
+ super().__init__(detail or self.title)
98
+
99
+
100
+ class Unauthorized(ApiError):
101
+ """The request lacks valid authentication (→ 401)."""
102
+
103
+ status = 401
104
+ type_slug = "unauthorized"
105
+ title = "Unauthorized"
106
+
107
+
108
+ class ResourceNotFound(ApiError):
109
+ """A requested resource does not exist (→ 404)."""
110
+
111
+ status = 404
112
+ type_slug = "not-found"
113
+ title = "Resource not found"
114
+
115
+
116
+ class BadRequest(ApiError):
117
+ """The request was malformed or semantically invalid (→ 400)."""
118
+
119
+ status = 400
120
+ type_slug = "bad-request"
121
+ title = "Bad request"
122
+
123
+
124
+ class Conflict(ApiError):
125
+ """The request conflicts with current state (→ 409)."""
126
+
127
+ status = 409
128
+ type_slug = "conflict"
129
+ title = "Conflict"
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Domain-exception mapping (by fully-qualified name — no heavy imports)
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class _ErrorSpec:
139
+ type_slug: str
140
+ status: int
141
+ title: str
142
+
143
+
144
+ #: Real, shipped exceptions → problem spec. Keyed by ``module.qualname`` so this
145
+ #: module imports none of them; the MRO walk matches a subclass at its nearest
146
+ #: registered ancestor (e.g. ``MaxTurnsExceeded`` → ``AgentError``).
147
+ _ERROR_MAP: dict[str, _ErrorSpec] = {
148
+ "valved.cli.orchestrator.builder.ConfigDriftError": _ErrorSpec(
149
+ "config-drift", 409, "Plan was generated against a different config"
150
+ ),
151
+ "valved.cli.orchestrator.builder.PlanExpiredError": _ErrorSpec(
152
+ "plan-expired", 409, "Plan has expired"
153
+ ),
154
+ "valved.cli.orchestrator.builder.BuildError": _ErrorSpec(
155
+ "build-failed", 400, "Build could not proceed"
156
+ ),
157
+ "valved.cli.orchestrator.planner.PlanGenerationError": _ErrorSpec(
158
+ "plan-generation", 422, "Plan generation failed"
159
+ ),
160
+ "valved.core.memory.writer.DecisionAlreadyExists": _ErrorSpec(
161
+ "decision-exists", 409, "Decision already exists"
162
+ ),
163
+ "valved.core.config.exceptions.ConfigError": _ErrorSpec(
164
+ "config-invalid", 400, "Configuration error"
165
+ ),
166
+ "valved.core.config.pipeline_schema.PipelineError": _ErrorSpec(
167
+ "pipeline-invalid", 400, "Pipeline definition error"
168
+ ),
169
+ "valved.core.connectors.exceptions.SnowflakeError": _ErrorSpec(
170
+ "snowflake", 502, "Snowflake error"
171
+ ),
172
+ "valved.core.targets.resolution.TargetResolutionError": _ErrorSpec(
173
+ "target-resolution", 400, "Target resolution error"
174
+ ),
175
+ "valved.core.agents.exceptions.AgentError": _ErrorSpec("agent", 500, "Agent run failed"),
176
+ "valved.core.hooks.runner.HookExecutionError": _ErrorSpec(
177
+ "hook-execution", 500, "Hook execution failed"
178
+ ),
179
+ "valved.core.hooks.config.HookConfigError": _ErrorSpec(
180
+ "hook-config", 400, "Hook configuration error"
181
+ ),
182
+ "valved.core.deploy.ddl_applier.UnsafeDdlError": _ErrorSpec(
183
+ "unsafe-ddl", 400, "Unsafe DDL rejected"
184
+ ),
185
+ "valved.core.mcp.config.McpConfigError": _ErrorSpec(
186
+ "mcp-config", 400, "MCP configuration error"
187
+ ),
188
+ "valved.core.agents.loader.AgentLoadError": _ErrorSpec(
189
+ "agent-load", 400, "Agent definition error"
190
+ ),
191
+ "valved.core.skills.packs.SkillPackError": _ErrorSpec("skill-pack", 400, "Skill pack error"),
192
+ "valved.core.state.job_queue.PipelineAlreadyRunning": _ErrorSpec(
193
+ "pipeline-already-running", 409, "Pipeline is already running"
194
+ ),
195
+ "valved.core.state.job_queue.QueuedJobAlreadyExists": _ErrorSpec(
196
+ "job-already-queued", 409, "A job is already queued for this pipeline"
197
+ ),
198
+ "valved.core.state.schedules.ScheduleNotFound": _ErrorSpec(
199
+ "schedule-not-found", 404, "Schedule not found"
200
+ ),
201
+ "valved.init.plan.InitError": _ErrorSpec("init-failed", 400, "Initialization error"),
202
+ }
203
+
204
+
205
+ def _fqname(cls: type) -> str:
206
+ return f"{cls.__module__}.{cls.__qualname__}"
207
+
208
+
209
+ def _lookup_spec(exc: BaseException) -> _ErrorSpec | None:
210
+ """Return the nearest registered spec for ``exc`` (walking its MRO)."""
211
+ for cls in type(exc).__mro__:
212
+ spec = _ERROR_MAP.get(_fqname(cls))
213
+ if spec is not None:
214
+ return spec
215
+ return None
216
+
217
+
218
+ def _extra_fields(exc: BaseException) -> dict[str, Any]:
219
+ """Pull structured custom fields off known exceptions (by attribute).
220
+
221
+ Read with ``getattr`` so this stays import-free. The ``ConfigDriftError``
222
+ round-trip in the spec (``plan_id`` + ``expected_config_hash`` +
223
+ ``actual_config_hash`` + ``recovery_hint``) is produced here.
224
+ """
225
+ extra: dict[str, Any] = {}
226
+ if _fqname(type(exc)) == "valved.cli.orchestrator.builder.ConfigDriftError":
227
+ extra["plan_id"] = getattr(exc, "plan_id", None)
228
+ extra["expected_config_hash"] = getattr(exc, "plan_hash", None)
229
+ extra["actual_config_hash"] = getattr(exc, "current_hash", None)
230
+ extra["recovery_hint"] = "Run `valved plan --refine` to regenerate against current config."
231
+ # ConfigError carries a dotted ``field`` and a remediation ``hint``.
232
+ field = getattr(exc, "field", None)
233
+ if field is not None:
234
+ extra.setdefault("field", field)
235
+ hint = getattr(exc, "hint", None)
236
+ if hint is not None:
237
+ extra.setdefault("recovery_hint", hint)
238
+ return extra
239
+
240
+
241
+ def _detail_of(exc: BaseException) -> str | None:
242
+ """Prefer a structured ``.message`` (e.g. ``ConfigError``) over ``str(exc)``."""
243
+ message = getattr(exc, "message", None)
244
+ if isinstance(message, str) and message:
245
+ return message
246
+ text = str(exc)
247
+ return text or None
248
+
249
+
250
+ class ProblemJsonExceptionHandler:
251
+ """Convert any exception to problem+json; unrecognized → 500 (stack logged)."""
252
+
253
+ async def __call__(self, request: Request, exc: Exception) -> Response:
254
+ instance = request.url.path
255
+ if isinstance(exc, ApiError):
256
+ return problem(
257
+ exc.status,
258
+ exc.type_slug,
259
+ exc.title,
260
+ detail=exc.detail,
261
+ instance=instance,
262
+ **exc.extra,
263
+ )
264
+ spec = _lookup_spec(exc)
265
+ if spec is None:
266
+ # Never leak internals: log the stack, return an opaque 500.
267
+ logger.exception("unhandled exception serving %s", instance)
268
+ return problem(
269
+ 500,
270
+ "internal",
271
+ "Internal server error",
272
+ detail="An unexpected error occurred.",
273
+ instance=instance,
274
+ )
275
+ return problem(
276
+ spec.status,
277
+ spec.type_slug,
278
+ spec.title,
279
+ detail=_detail_of(exc),
280
+ instance=instance,
281
+ **_extra_fields(exc),
282
+ )
283
+
284
+
285
+ async def _validation_handler(request: Request, exc: Exception) -> Response:
286
+ """FastAPI request-validation errors → 422 problem+json."""
287
+ errors = exc.errors() if isinstance(exc, RequestValidationError) else []
288
+ return problem(
289
+ 422,
290
+ "validation",
291
+ "Request validation failed",
292
+ detail="One or more request parameters were invalid.",
293
+ instance=request.url.path,
294
+ errors=errors,
295
+ )
296
+
297
+
298
+ async def _http_exception_handler(request: Request, exc: Exception) -> Response:
299
+ """Starlette ``HTTPException`` (e.g. 404 on an unknown route) → problem+json."""
300
+ status = exc.status_code if isinstance(exc, StarletteHTTPException) else 500
301
+ detail = exc.detail if isinstance(exc, StarletteHTTPException) else None
302
+ slug = {
303
+ 401: "unauthorized",
304
+ 403: "forbidden",
305
+ 404: "not-found",
306
+ 405: "method-not-allowed",
307
+ }.get(status, "http-error")
308
+ return problem(
309
+ status,
310
+ slug,
311
+ str(detail) if detail else "HTTP error",
312
+ instance=request.url.path,
313
+ )
314
+
315
+
316
+ def install_error_handlers(app: FastAPI) -> None:
317
+ """Register the problem+json handlers on ``app`` (validation, HTTP, catch-all)."""
318
+ app.add_exception_handler(RequestValidationError, _validation_handler)
319
+ app.add_exception_handler(StarletteHTTPException, _http_exception_handler)
320
+ app.add_exception_handler(Exception, ProblemJsonExceptionHandler())
321
+
322
+
323
+ __all__ = [
324
+ "PROBLEM_JSON_MEDIA_TYPE",
325
+ "ApiError",
326
+ "BadRequest",
327
+ "Conflict",
328
+ "ProblemJsonExceptionHandler",
329
+ "ResourceNotFound",
330
+ "Unauthorized",
331
+ "install_error_handlers",
332
+ "problem",
333
+ ]