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.
- valved/__init__.py +5 -0
- valved/__main__.py +6 -0
- valved/api/__init__.py +11 -0
- valved/api/auth.py +141 -0
- valved/api/dependencies.py +70 -0
- valved/api/errors.py +333 -0
- valved/api/idempotency.py +174 -0
- valved/api/main.py +120 -0
- valved/api/openapi_meta.py +76 -0
- valved/api/pagination.py +159 -0
- valved/api/routers/__init__.py +6 -0
- valved/api/routers/agents.py +81 -0
- valved/api/routers/asks.py +206 -0
- valved/api/routers/builds.py +142 -0
- valved/api/routers/components.py +62 -0
- valved/api/routers/deploys.py +234 -0
- valved/api/routers/health.py +78 -0
- valved/api/routers/investigations.py +147 -0
- valved/api/routers/jobs.py +73 -0
- valved/api/routers/mcp_servers.py +72 -0
- valved/api/routers/memory.py +138 -0
- valved/api/routers/metrics.py +104 -0
- valved/api/routers/pipelines.py +92 -0
- valved/api/routers/plans.py +136 -0
- valved/api/routers/runs.py +271 -0
- valved/api/routers/schedules.py +152 -0
- valved/api/routers/skills.py +71 -0
- valved/api/routers/targets.py +74 -0
- valved/api/routers/tokens.py +79 -0
- valved/api/routers/webhooks.py +112 -0
- valved/api/routers/workers.py +54 -0
- valved/api/schemas.py +46 -0
- valved/api/streams.py +178 -0
- valved/api/webhooks.py +265 -0
- valved/cli/__init__.py +1 -0
- valved/cli/commands/__init__.py +6 -0
- valved/cli/commands/agents.py +264 -0
- valved/cli/commands/ask.py +266 -0
- valved/cli/commands/asks.py +166 -0
- valved/cli/commands/auth.py +133 -0
- valved/cli/commands/build.py +355 -0
- valved/cli/commands/component/__init__.py +421 -0
- valved/cli/commands/component/graduation.py +205 -0
- valved/cli/commands/connect.py +236 -0
- valved/cli/commands/deploy.py +247 -0
- valved/cli/commands/docs.py +256 -0
- valved/cli/commands/el/__init__.py +56 -0
- valved/cli/commands/el/list.py +217 -0
- valved/cli/commands/el/run.py +361 -0
- valved/cli/commands/el/verify.py +185 -0
- valved/cli/commands/init.py +296 -0
- valved/cli/commands/investigations.py +175 -0
- valved/cli/commands/logs.py +45 -0
- valved/cli/commands/mcp_serve.py +169 -0
- valved/cli/commands/mcp_servers.py +119 -0
- valved/cli/commands/memory.py +308 -0
- valved/cli/commands/metrics/__init__.py +32 -0
- valved/cli/commands/metrics/commands.py +191 -0
- valved/cli/commands/packaging.py +132 -0
- valved/cli/commands/pipelines.py +321 -0
- valved/cli/commands/plan.py +280 -0
- valved/cli/commands/plan_and_build.py +171 -0
- valved/cli/commands/run.py +173 -0
- valved/cli/commands/runs.py +69 -0
- valved/cli/commands/schedule/__init__.py +59 -0
- valved/cli/commands/schedule/commands.py +249 -0
- valved/cli/commands/serve.py +501 -0
- valved/cli/commands/skills.py +149 -0
- valved/cli/commands/target/__init__.py +23 -0
- valved/cli/commands/target/create.py +62 -0
- valved/cli/commands/target/delete.py +108 -0
- valved/cli/commands/target/list.py +76 -0
- valved/cli/commands/target/rename.py +93 -0
- valved/cli/commands/target/show.py +94 -0
- valved/cli/commands/version.py +14 -0
- valved/cli/commands/worker.py +196 -0
- valved/cli/dotenv.py +111 -0
- valved/cli/main.py +130 -0
- valved/cli/orchestrator/__init__.py +52 -0
- valved/cli/orchestrator/builder.py +1463 -0
- valved/cli/orchestrator/cost_rollup.py +179 -0
- valved/cli/orchestrator/delegation_run.py +507 -0
- valved/cli/orchestrator/extensibility_wiring.py +328 -0
- valved/cli/orchestrator/extra_tools.py +179 -0
- valved/cli/orchestrator/failure_taxonomy.py +150 -0
- valved/cli/orchestrator/goal_classifier.py +212 -0
- valved/cli/orchestrator/goal_decomposer.py +270 -0
- valved/cli/orchestrator/listing.py +381 -0
- valved/cli/orchestrator/observers.py +223 -0
- valved/cli/orchestrator/planner.py +1235 -0
- valved/cli/orchestrator/recovery.py +343 -0
- valved/cli/orchestrator/recovery_trigger.py +321 -0
- valved/cli/orchestrator/review_wiring.py +293 -0
- valved/cli/orchestrator/runner.py +606 -0
- valved/core/__init__.py +6 -0
- valved/core/agents/__init__.py +103 -0
- valved/core/agents/builtin/README.md +19 -0
- valved/core/agents/builtin/dbt-engineer.md +188 -0
- valved/core/agents/builtin/dbt-qa.md +83 -0
- valved/core/agents/builtin/dlt-engineer.md +208 -0
- valved/core/agents/builtin/dlt-qa.md +85 -0
- valved/core/agents/builtin/dlt-security.md +80 -0
- valved/core/agents/builtin/explorer.md +221 -0
- valved/core/agents/builtin/pipeline-engineer.md +210 -0
- valved/core/agents/builtin/sql-specialist.md +47 -0
- valved/core/agents/cancel.py +57 -0
- valved/core/agents/client_factory.py +261 -0
- valved/core/agents/compaction.py +77 -0
- valved/core/agents/delegation.py +309 -0
- valved/core/agents/discovery.py +232 -0
- valved/core/agents/exceptions.py +30 -0
- valved/core/agents/lint.py +77 -0
- valved/core/agents/loader.py +325 -0
- valved/core/agents/loop.py +659 -0
- valved/core/agents/m1_tools.py +555 -0
- valved/core/agents/observer.py +123 -0
- valved/core/agents/orchestrator_hooks/__init__.py +14 -0
- valved/core/agents/orchestrator_hooks/memory.py +60 -0
- valved/core/agents/permissions/__init__.py +73 -0
- valved/core/agents/permissions/bash_gate.py +207 -0
- valved/core/agents/permissions/gate.py +237 -0
- valved/core/agents/permissions/modes.py +107 -0
- valved/core/agents/permissions/policy.py +817 -0
- valved/core/agents/permissions/warehouse_roles.py +70 -0
- valved/core/agents/pricing.py +113 -0
- valved/core/agents/prompts/m1_build_agent.md +109 -0
- valved/core/agents/prompts/m1_plan_agent.md +108 -0
- valved/core/agents/prompts/recovery_el_run_agent.md +69 -0
- valved/core/agents/recovery/__init__.py +82 -0
- valved/core/agents/recovery/agent.py +601 -0
- valved/core/agents/recovery/classify.py +247 -0
- valved/core/agents/recovery/invocation.py +97 -0
- valved/core/agents/recovery/resolve.py +48 -0
- valved/core/agents/recovery/schema_diff.py +150 -0
- valved/core/agents/recovery/trigger.py +419 -0
- valved/core/agents/review_fan_out.py +229 -0
- valved/core/agents/routing.py +81 -0
- valved/core/agents/run_explorer.py +508 -0
- valved/core/agents/steering.py +52 -0
- valved/core/agents/subagent_registry.py +258 -0
- valved/core/agents/tool_binding.py +113 -0
- valved/core/agents/tools/__init__.py +74 -0
- valved/core/agents/tools/bash_tool.py +282 -0
- valved/core/agents/tools/fs_tools.py +273 -0
- valved/core/agents/tools/search_tools.py +202 -0
- valved/core/agents/tools/secrets_denylist.py +75 -0
- valved/core/agents/tools/submit_result.py +125 -0
- valved/core/agents/tools/todo_tool.py +107 -0
- valved/core/agents/tools/web_tools.py +119 -0
- valved/core/agents/verification.py +175 -0
- valved/core/asks/__init__.py +35 -0
- valved/core/asks/citation_builder.py +236 -0
- valved/core/asks/models.py +134 -0
- valved/core/config/__init__.py +80 -0
- valved/core/config/exceptions.py +48 -0
- valved/core/config/loader.py +248 -0
- valved/core/config/paths.py +74 -0
- valved/core/config/pipeline_schema.py +590 -0
- valved/core/config/schema.py +755 -0
- valved/core/config/state_store.py +77 -0
- valved/core/connect/__init__.py +54 -0
- valved/core/connect/dbt_provision.py +198 -0
- valved/core/connect/installer.py +221 -0
- valved/core/connect/result.py +126 -0
- valved/core/connectors/__init__.py +25 -0
- valved/core/connectors/duckdb.py +65 -0
- valved/core/connectors/exceptions.py +41 -0
- valved/core/connectors/snowflake.py +311 -0
- valved/core/dbt_execution/__init__.py +60 -0
- valved/core/dbt_execution/backend.py +129 -0
- valved/core/dbt_execution/engine.py +119 -0
- valved/core/dbt_execution/local.py +159 -0
- valved/core/dbt_execution/result.py +136 -0
- valved/core/dbt_execution/verify_bridge.py +235 -0
- valved/core/deploy/__init__.py +70 -0
- valved/core/deploy/cross_repo.py +691 -0
- valved/core/deploy/ddl_applier.py +304 -0
- valved/core/deploy/git_promote.py +711 -0
- valved/core/deploy/identifiers.py +60 -0
- valved/core/deploy/preflight.py +450 -0
- valved/core/deploy/recovery.py +143 -0
- valved/core/deploy/templates/commit_message.j2 +11 -0
- valved/core/deploy/templates/pr_body.md.j2 +36 -0
- valved/core/deploy/verifier.py +180 -0
- valved/core/hooks/__init__.py +55 -0
- valved/core/hooks/config.py +146 -0
- valved/core/hooks/events.py +130 -0
- valved/core/hooks/runner.py +124 -0
- valved/core/hooks/wiring.py +201 -0
- valved/core/mcp/__init__.py +46 -0
- valved/core/mcp/client.py +127 -0
- valved/core/mcp/config.py +217 -0
- valved/core/memory/__init__.py +31 -0
- valved/core/memory/loader.py +125 -0
- valved/core/memory/selector.py +72 -0
- valved/core/memory/writer.py +196 -0
- valved/core/observability/__init__.py +36 -0
- valved/core/observability/recording.py +207 -0
- valved/core/observability/rollups.py +272 -0
- valved/core/redaction.py +148 -0
- valved/core/runners/__init__.py +21 -0
- valved/core/runners/base.py +69 -0
- valved/core/runners/local_venv.py +472 -0
- valved/core/runners/subprocess.py +126 -0
- valved/core/skills/__init__.py +46 -0
- valved/core/skills/builtin/__init__.py +58 -0
- valved/core/skills/builtin/catalog.py +268 -0
- valved/core/skills/builtin/dlt_strategies/curated_library/SKILL.md +65 -0
- valved/core/skills/builtin/dlt_strategies/native_dlt/SKILL.md +65 -0
- valved/core/skills/builtin/dlt_strategies/rest_api_config/SKILL.md +62 -0
- valved/core/skills/builtin/dlt_strategies/singer_wrapper/SKILL.md +57 -0
- valved/core/skills/context.py +71 -0
- valved/core/skills/decorator.py +80 -0
- valved/core/skills/executor.py +68 -0
- valved/core/skills/pack_discovery.py +220 -0
- valved/core/skills/packs.py +144 -0
- valved/core/skills/registry.py +153 -0
- valved/core/skills/result.py +30 -0
- valved/core/sql/__init__.py +40 -0
- valved/core/sql/classify.py +131 -0
- valved/core/sql/dialects.py +90 -0
- valved/core/sql/introspect.py +204 -0
- valved/core/sql/tool.py +181 -0
- valved/core/state/__init__.py +82 -0
- valved/core/state/asks.py +173 -0
- valved/core/state/database.py +133 -0
- valved/core/state/deploys.py +160 -0
- valved/core/state/events_read.py +107 -0
- valved/core/state/idempotency.py +116 -0
- valved/core/state/investigations.py +220 -0
- valved/core/state/job_queue.py +755 -0
- valved/core/state/models.py +1069 -0
- valved/core/state/repository.py +623 -0
- valved/core/state/schedules.py +680 -0
- valved/core/state/store.py +80 -0
- valved/core/state/telemetry.py +210 -0
- valved/core/state/tokens.py +215 -0
- valved/core/state/webhooks.py +303 -0
- valved/core/steps/__init__.py +23 -0
- valved/core/steps/base.py +94 -0
- valved/core/steps/python.py +71 -0
- valved/core/targets/__init__.py +60 -0
- valved/core/targets/destination.py +293 -0
- valved/core/targets/names.py +59 -0
- valved/core/targets/registry.py +465 -0
- valved/core/targets/resolution.py +106 -0
- valved/init/__init__.py +36 -0
- valved/init/detect.py +85 -0
- valved/init/plan.py +201 -0
- valved/init/scaffold.py +93 -0
- valved/init/templates.py +340 -0
- valved/integrations/__init__.py +50 -0
- valved/integrations/component_locator.py +329 -0
- valved/integrations/dbt/__init__.py +48 -0
- valved/integrations/dbt/conventions.py +779 -0
- valved/integrations/dbt/manifest.py +350 -0
- valved/integrations/dbt/runner.py +134 -0
- valved/integrations/dbt/sources.py +197 -0
- valved/integrations/dbt/verify.py +263 -0
- valved/integrations/dlt/__init__.py +46 -0
- valved/integrations/dlt/code_emitter.py +83 -0
- valved/integrations/dlt/library.py +401 -0
- valved/integrations/dlt/runner.py +143 -0
- valved/integrations/dlt/schema_reader.py +624 -0
- valved/integrations/dlt/skills.py +225 -0
- valved/integrations/dlt/verify.py +210 -0
- valved/integrations/provenance.py +176 -0
- valved/integrations/workspace_cache.py +299 -0
- valved/mcp/__init__.py +32 -0
- valved/mcp/adapter.py +151 -0
- valved/mcp/auth.py +58 -0
- valved/mcp/server.py +229 -0
- valved/mcp/tool_generator.py +263 -0
- valved/py.typed +0 -0
- valved/runtime/__init__.py +91 -0
- valved/runtime/archiver.py +374 -0
- valved/runtime/clock.py +114 -0
- valved/runtime/component_resolution.py +93 -0
- valved/runtime/cron.py +127 -0
- valved/runtime/events.py +99 -0
- valved/runtime/execute_pipeline.py +345 -0
- valved/runtime/failure_modes.py +140 -0
- valved/runtime/heartbeat.py +127 -0
- valved/runtime/jinja_context.py +143 -0
- valved/runtime/persisting_step_sink.py +136 -0
- valved/runtime/pipeline_dag.py +171 -0
- valved/runtime/pipeline_verify.py +240 -0
- valved/runtime/reaper.py +145 -0
- valved/runtime/run_context.py +45 -0
- valved/runtime/scheduler.py +190 -0
- valved/runtime/skills/__init__.py +75 -0
- valved/runtime/skills/list_components.py +96 -0
- valved/runtime/skills/list_dbt_models.py +82 -0
- valved/runtime/skills/pipeline_inspect.py +175 -0
- valved/runtime/step_executor.py +142 -0
- valved/runtime/step_types/__init__.py +33 -0
- valved/runtime/step_types/connections.py +132 -0
- valved/runtime/step_types/dbt.py +245 -0
- valved/runtime/step_types/dlt.py +517 -0
- valved/runtime/step_types/registry.py +110 -0
- valved/runtime/step_types/sql.py +354 -0
- valved/runtime/worker.py +559 -0
- valved/runtime/worker_pool.py +196 -0
- valved/sources/__init__.py +17 -0
- valved/sources/_reference_hackernews/SKILL.md +77 -0
- valved/sources/_reference_hackernews/scripts/__init__.py +62 -0
- valved/sources/_reference_hackernews/scripts/introspect.py +48 -0
- valved/sources/_reference_hackernews/scripts/requirements.txt +3 -0
- valved/ui/__init__.py +16 -0
- valved/ui/markdown.py +48 -0
- valved/ui/queries.py +985 -0
- valved/ui/render.py +376 -0
- valved/ui/server.py +119 -0
- valved/ui/static/favicon.svg +4 -0
- valved/ui/static/valved.css +209 -0
- valved/ui/static/valved.js +54 -0
- valved/ui/templates/_macros.html.j2 +97 -0
- valved/ui/templates/agents.html.j2 +47 -0
- valved/ui/templates/base.html.j2 +37 -0
- valved/ui/templates/index.html.j2 +88 -0
- valved/ui/templates/pipeline_detail.html.j2 +61 -0
- valved/ui/templates/pipelines.html.j2 +39 -0
- valved/ui/templates/run_detail.html.j2 +82 -0
- valved/ui/templates/runs.html.j2 +14 -0
- valved/ui/templates/skills.html.j2 +74 -0
- valved/ui/triggers.py +202 -0
- valved/ui/urls.py +184 -0
- valved/version.py +14 -0
- valved-0.1.0.dist-info/METADATA +130 -0
- valved-0.1.0.dist-info/RECORD +333 -0
- valved-0.1.0.dist-info/WHEEL +4 -0
- valved-0.1.0.dist-info/entry_points.txt +2 -0
- valved-0.1.0.dist-info/licenses/LICENSE +201 -0
valved/__init__.py
ADDED
valved/__main__.py
ADDED
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
|
+
]
|