lorebase-keeper 0.2.3__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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Domains (design 5.2, 11.1): create, list, read, update display metadata, delete.
|
|
2
|
+
|
|
3
|
+
Creating a Domain needs the installation-level `domain.create` permission; the creator gets
|
|
4
|
+
the configured role (`keeper.domains.creator_role`, default `admin`) in the new Domain.
|
|
5
|
+
Deleting one needs `domain.delete`, which only the Domain's `admin` role holds, and the Domain
|
|
6
|
+
id repeated in `confirm`: the deletion removes every Revision, branch and grant of the Domain
|
|
7
|
+
and cannot be undone (the audit log keeps its entries).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Annotated, Any
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, Query, Request, Response
|
|
16
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
17
|
+
|
|
18
|
+
from lorebase_core.canonical import hex
|
|
19
|
+
from lorebase_core.model import Domain
|
|
20
|
+
from lorebase_keeper.auth.audit import annotate
|
|
21
|
+
from lorebase_keeper.auth.deps import (
|
|
22
|
+
Access,
|
|
23
|
+
AuthDep,
|
|
24
|
+
PrincipalDep,
|
|
25
|
+
limited_kinds,
|
|
26
|
+
require,
|
|
27
|
+
require_installation,
|
|
28
|
+
)
|
|
29
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
30
|
+
from lorebase_keeper.context import StoreDep
|
|
31
|
+
from lorebase_keeper.deps import IdempotencyKeyDep, KeeperSettingsDep
|
|
32
|
+
from lorebase_keeper.errors import VALIDATION_FAILED, Problem, ProblemError, ValidationProblem
|
|
33
|
+
from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
|
|
34
|
+
from lorebase_keeper.services import domains as service
|
|
35
|
+
from lorebase_keeper.services.branches import check_ref_name
|
|
36
|
+
from lorebase_store.repositories import DomainRow
|
|
37
|
+
|
|
38
|
+
router = APIRouter(tags=["domains"])
|
|
39
|
+
|
|
40
|
+
DOMAIN_ID_PATTERN = r"^[a-z][a-z0-9_-]*$"
|
|
41
|
+
REPLAYED_HEADER = "Idempotent-Replayed"
|
|
42
|
+
|
|
43
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
44
|
+
403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
|
|
45
|
+
404: {"model": Problem, "description": "Domain not found."},
|
|
46
|
+
409: {"model": Problem, "description": "The Domain already exists."},
|
|
47
|
+
422: {"model": ValidationProblem, "description": "Invalid request."},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DomainCreate(BaseModel):
|
|
52
|
+
"""A new Domain."""
|
|
53
|
+
|
|
54
|
+
id: str = Field(pattern=DOMAIN_ID_PATTERN, max_length=63, description="Immutable slug.")
|
|
55
|
+
display_name: str = Field(default="", max_length=200)
|
|
56
|
+
description: str = Field(default="", max_length=4000)
|
|
57
|
+
default_branch: str = Field(default="main", min_length=1, max_length=200)
|
|
58
|
+
settings: dict[str, JsonValue] = Field(
|
|
59
|
+
default_factory=dict, description="Search language, materialization and merge policies."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DomainUpdate(BaseModel):
|
|
64
|
+
"""Display metadata; omitted members are left unchanged."""
|
|
65
|
+
|
|
66
|
+
display_name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
67
|
+
description: str | None = Field(default=None, max_length=4000)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DomainOut(BaseModel):
|
|
71
|
+
"""A Domain and the head Revision of its default branch."""
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
display_name: str
|
|
75
|
+
description: str | None
|
|
76
|
+
default_branch: str
|
|
77
|
+
settings: dict[str, Any]
|
|
78
|
+
created_at: datetime
|
|
79
|
+
created_by: str
|
|
80
|
+
head: str | None = Field(description="Head Revision of the default branch.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _out(row: DomainRow, head: bytes | None) -> DomainOut:
|
|
84
|
+
return DomainOut(
|
|
85
|
+
id=row.id,
|
|
86
|
+
display_name=row.display_name,
|
|
87
|
+
description=row.description,
|
|
88
|
+
default_branch=row.default_branch,
|
|
89
|
+
settings=dict(row.settings),
|
|
90
|
+
created_at=row.created_at,
|
|
91
|
+
created_by=row.created_by,
|
|
92
|
+
head=None if head is None else hex(head),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.post("/domains", status_code=201, summary="Create a Domain", responses=_ERRORS)
|
|
97
|
+
async def create_domain(
|
|
98
|
+
body: DomainCreate,
|
|
99
|
+
request: Request,
|
|
100
|
+
response: Response,
|
|
101
|
+
store: StoreDep,
|
|
102
|
+
keeper: KeeperSettingsDep,
|
|
103
|
+
auth: AuthDep,
|
|
104
|
+
idempotency_key: IdempotencyKeyDep,
|
|
105
|
+
access: Annotated[Access, Depends(require_installation(Permission.DOMAIN_CREATE))],
|
|
106
|
+
) -> DomainOut:
|
|
107
|
+
"""Create a Domain with an initial Revision on its default branch (protected by default).
|
|
108
|
+
|
|
109
|
+
With an `Idempotency-Key`, retrying a create that succeeded returns the Domain with
|
|
110
|
+
status 200 and `Idempotent-Replayed: true`.
|
|
111
|
+
"""
|
|
112
|
+
check_ref_name(body.default_branch, keeper.branches, "/body/default_branch")
|
|
113
|
+
domain = Domain(
|
|
114
|
+
id=body.id,
|
|
115
|
+
display_name=body.display_name,
|
|
116
|
+
description=body.description,
|
|
117
|
+
default_branch=body.default_branch,
|
|
118
|
+
settings=body.settings,
|
|
119
|
+
)
|
|
120
|
+
created = await service.create(
|
|
121
|
+
store, domain, access.principal, keeper.domains, idempotency_key=idempotency_key
|
|
122
|
+
)
|
|
123
|
+
auth.authorizer.invalidate()
|
|
124
|
+
annotate(request, domain_id=body.id, action="domain.create", target=body.id)
|
|
125
|
+
if created.replayed:
|
|
126
|
+
response.status_code = 200
|
|
127
|
+
response.headers[REPLAYED_HEADER] = "true"
|
|
128
|
+
response.headers["Location"] = f"{keeper.api_prefix}/domains/{body.id}"
|
|
129
|
+
return _out(created.domain, created.head)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.get("/domains", summary="List Domains", responses=_ERRORS)
|
|
133
|
+
async def list_domains(
|
|
134
|
+
principal: PrincipalDep, auth: AuthDep, store: StoreDep, params: PageParamsDep
|
|
135
|
+
) -> Page[DomainOut]:
|
|
136
|
+
"""Domains whose schema the caller may read (also through grants narrowed to Kinds that
|
|
137
|
+
are invisible to it otherwise), ordered by id."""
|
|
138
|
+
after = None if params.position is None else str(params.position.get("id", ""))
|
|
139
|
+
items: list[DomainOut] = []
|
|
140
|
+
more = True
|
|
141
|
+
# Domains the caller cannot see are skipped: read further batches until the page is full.
|
|
142
|
+
while more and len(items) < params.limit:
|
|
143
|
+
rows = await service.page(store, limit=params.limit, after=after)
|
|
144
|
+
more = len(rows) == params.limit
|
|
145
|
+
for position, row in enumerate(rows):
|
|
146
|
+
after = row.id
|
|
147
|
+
if await auth.authorizer.allows(
|
|
148
|
+
principal, Permission.SCHEMA_READ, row.id
|
|
149
|
+
) or await limited_kinds(auth.authorizer, principal, Permission.SCHEMA_READ, row.id):
|
|
150
|
+
items.append(_out(row, None))
|
|
151
|
+
if len(items) == params.limit:
|
|
152
|
+
more = more or position < len(rows) - 1
|
|
153
|
+
break
|
|
154
|
+
next_cursor = encode_cursor({"id": after}) if more and after is not None else None
|
|
155
|
+
return Page[DomainOut](items=items, next_cursor=next_cursor)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("/domains/{domain_id}", summary="Get a Domain", responses=_ERRORS)
|
|
159
|
+
async def get_domain(
|
|
160
|
+
domain_id: str,
|
|
161
|
+
store: StoreDep,
|
|
162
|
+
_: Annotated[Access, Depends(require(Permission.SCHEMA_READ, kind_limited=True))],
|
|
163
|
+
) -> DomainOut:
|
|
164
|
+
"""The Domain and the head Revision of its default branch."""
|
|
165
|
+
row, head = await service.get(store, domain_id)
|
|
166
|
+
return _out(row, head)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.patch("/domains/{domain_id}", summary="Update Domain display metadata", responses=_ERRORS)
|
|
170
|
+
async def update_domain(
|
|
171
|
+
domain_id: str,
|
|
172
|
+
body: DomainUpdate,
|
|
173
|
+
request: Request,
|
|
174
|
+
store: StoreDep,
|
|
175
|
+
_: Annotated[Access, Depends(require(Permission.DOMAIN_SETTINGS))],
|
|
176
|
+
) -> DomainOut:
|
|
177
|
+
"""Change the display name or description. They are not versioned."""
|
|
178
|
+
await service.update(
|
|
179
|
+
store, domain_id, display_name=body.display_name, description=body.description
|
|
180
|
+
)
|
|
181
|
+
annotate(
|
|
182
|
+
request,
|
|
183
|
+
action="domain.update",
|
|
184
|
+
target=domain_id,
|
|
185
|
+
fields=sorted(body.model_dump(exclude_unset=True)),
|
|
186
|
+
)
|
|
187
|
+
row, head = await service.get(store, domain_id)
|
|
188
|
+
return _out(row, head)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.delete(
|
|
192
|
+
"/domains/{domain_id}", status_code=204, summary="Delete a Domain", responses=_ERRORS
|
|
193
|
+
)
|
|
194
|
+
async def delete_domain(
|
|
195
|
+
domain_id: str,
|
|
196
|
+
request: Request,
|
|
197
|
+
store: StoreDep,
|
|
198
|
+
auth: AuthDep,
|
|
199
|
+
confirm: Annotated[str, Query(description="The Domain id, repeated to confirm.")],
|
|
200
|
+
_: Annotated[Access, Depends(require(Permission.DOMAIN_DELETE))],
|
|
201
|
+
) -> Response:
|
|
202
|
+
"""Delete the Domain with all its Revisions, branches, materialized rows and grants."""
|
|
203
|
+
if confirm != domain_id:
|
|
204
|
+
raise ProblemError(
|
|
205
|
+
VALIDATION_FAILED,
|
|
206
|
+
"One or more request members are invalid.",
|
|
207
|
+
extensions={
|
|
208
|
+
"errors": [
|
|
209
|
+
{
|
|
210
|
+
"pointer": "/query/confirm",
|
|
211
|
+
"message": "must repeat the Domain id",
|
|
212
|
+
"code": "invalid",
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
await service.delete(store, domain_id)
|
|
218
|
+
auth.authorizer.invalidate()
|
|
219
|
+
annotate(request, action="domain.delete", target=domain_id)
|
|
220
|
+
return Response(status_code=204)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Change feed of a branch (`agents.feed`): Server-Sent Events and the polling fallback.
|
|
2
|
+
|
|
3
|
+
Both are history reads: they need `data.read` on the branch through a grant not narrowed to a
|
|
4
|
+
Kind, like the branch log. An open stream repeats that check (authentication included) every
|
|
5
|
+
`agents.feed.reauthorize_interval_s` and closes when it fails.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends, Header, Query, Request, Response
|
|
14
|
+
from fastapi.responses import StreamingResponse
|
|
15
|
+
|
|
16
|
+
from lorebase_keeper.agents.feed import (
|
|
17
|
+
EVENT_STREAM,
|
|
18
|
+
ChangesFeedPage,
|
|
19
|
+
changes_since,
|
|
20
|
+
kind_filter,
|
|
21
|
+
parse_sequence,
|
|
22
|
+
stream,
|
|
23
|
+
)
|
|
24
|
+
from lorebase_keeper.auth.deps import AuthDep
|
|
25
|
+
from lorebase_keeper.client_address import client_address
|
|
26
|
+
from lorebase_keeper.errors import Problem, ProblemError, ValidationProblem
|
|
27
|
+
from lorebase_keeper.reading.caching import respond
|
|
28
|
+
from lorebase_keeper.reading.points import (
|
|
29
|
+
REVISION_HEADER,
|
|
30
|
+
branch_schema,
|
|
31
|
+
open_branch,
|
|
32
|
+
open_domain,
|
|
33
|
+
open_revision,
|
|
34
|
+
read_guard,
|
|
35
|
+
)
|
|
36
|
+
from lorebase_keeper.reading.problems import invalid
|
|
37
|
+
from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep, begin_read
|
|
38
|
+
from lorebase_keeper.routes.agents import AgentSettingsDep
|
|
39
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
40
|
+
from lorebase_store.repositories import OutboxRepository
|
|
41
|
+
|
|
42
|
+
router = APIRouter(tags=["agents"])
|
|
43
|
+
|
|
44
|
+
BASE = "/domains/{domain_id}"
|
|
45
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
46
|
+
403: {"model": Problem, "description": "The caller lacks data.read for the branch."},
|
|
47
|
+
404: {"model": Problem, "description": "Domain, branch or Revision not found."},
|
|
48
|
+
422: {"model": ValidationProblem, "description": "Invalid request."},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
DataRead = Annotated[ReadGuard, Depends(read_guard())]
|
|
52
|
+
Kinds = Annotated[
|
|
53
|
+
list[str] | None,
|
|
54
|
+
Query(description="Only events changing these Kinds (repeated or comma-separated)."),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get(
|
|
59
|
+
f"{BASE}/branches/{{branch:path}}/events",
|
|
60
|
+
summary="Stream the Revisions of a branch (Server-Sent Events)",
|
|
61
|
+
response_class=StreamingResponse,
|
|
62
|
+
responses={
|
|
63
|
+
**_ERRORS,
|
|
64
|
+
200: {
|
|
65
|
+
"content": {EVENT_STREAM: {}},
|
|
66
|
+
"description": "`event: revision` messages whose `data` is a RevisionEventOut and "
|
|
67
|
+
"whose `id` is the event sequence; comment lines as heartbeats.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
async def branch_events(
|
|
72
|
+
domain_id: str,
|
|
73
|
+
branch: str,
|
|
74
|
+
access: DataRead,
|
|
75
|
+
runtime: ReadRuntimeDep,
|
|
76
|
+
settings: AgentSettingsDep,
|
|
77
|
+
auth: AuthDep,
|
|
78
|
+
request: Request,
|
|
79
|
+
kinds: Kinds = None,
|
|
80
|
+
last_event_id: Annotated[
|
|
81
|
+
str | None,
|
|
82
|
+
Query(description="Resume after this event id (for clients that cannot set headers)."),
|
|
83
|
+
] = None,
|
|
84
|
+
last_event_header: Annotated[str | None, Header(alias="Last-Event-ID")] = None,
|
|
85
|
+
) -> StreamingResponse:
|
|
86
|
+
"""Revisions committed on the branch from now on, or after `Last-Event-ID`."""
|
|
87
|
+
async with runtime.sessions() as session, session.begin():
|
|
88
|
+
await begin_read(session, runtime)
|
|
89
|
+
domain = await open_domain(session, access)
|
|
90
|
+
ref = await open_branch(session, access, domain, branch)
|
|
91
|
+
wanted = kind_filter(await branch_schema(session, runtime, domain.id, ref.name), kinds)
|
|
92
|
+
start = parse_sequence(last_event_header or last_event_id)
|
|
93
|
+
if start is None:
|
|
94
|
+
start = await OutboxRepository(session).latest(domain.id)
|
|
95
|
+
|
|
96
|
+
async def still_allowed() -> bool:
|
|
97
|
+
"""The checks above, again: the credential (revoked or expired keys and tokens, the
|
|
98
|
+
networks of a key), then `data.read` on the branch. Grants come through the
|
|
99
|
+
authorizer's cache."""
|
|
100
|
+
try:
|
|
101
|
+
principal = await auth.authenticator.authenticate(
|
|
102
|
+
request.headers, client_address(request)
|
|
103
|
+
)
|
|
104
|
+
guard = replace(access, principal=principal)
|
|
105
|
+
await guard.precheck()
|
|
106
|
+
await guard.require_branch(ref.name)
|
|
107
|
+
except ProblemError:
|
|
108
|
+
return False
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
return StreamingResponse(
|
|
112
|
+
stream(
|
|
113
|
+
runtime,
|
|
114
|
+
domain.id,
|
|
115
|
+
ref.name,
|
|
116
|
+
start,
|
|
117
|
+
wanted,
|
|
118
|
+
settings.feed,
|
|
119
|
+
request.is_disconnected,
|
|
120
|
+
still_allowed,
|
|
121
|
+
),
|
|
122
|
+
media_type=EVENT_STREAM,
|
|
123
|
+
headers={
|
|
124
|
+
"Cache-Control": "no-cache",
|
|
125
|
+
"X-Accel-Buffering": "no",
|
|
126
|
+
REVISION_HEADER: ref.head.hex(),
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.get(
|
|
132
|
+
f"{BASE}/branches/{{branch:path}}/changes",
|
|
133
|
+
summary="Revisions of a branch since a Revision",
|
|
134
|
+
response_model=ChangesFeedPage,
|
|
135
|
+
responses={
|
|
136
|
+
**_ERRORS,
|
|
137
|
+
304: {"description": "Not modified: If-None-Match names the current ETag."},
|
|
138
|
+
409: {"model": Problem, "description": "`since` is not reachable from the head."},
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
async def branch_changes(
|
|
142
|
+
domain_id: str,
|
|
143
|
+
branch: str,
|
|
144
|
+
access: DataRead,
|
|
145
|
+
runtime: ReadRuntimeDep,
|
|
146
|
+
session: ReadSessionDep,
|
|
147
|
+
settings: AgentSettingsDep,
|
|
148
|
+
request: Request,
|
|
149
|
+
since: Annotated[
|
|
150
|
+
str | None,
|
|
151
|
+
Query(description="Revision id or tag; default: the latest Revisions."),
|
|
152
|
+
] = None,
|
|
153
|
+
kinds: Kinds = None,
|
|
154
|
+
limit: Annotated[int | None, Query(ge=1, description="Page size.")] = None,
|
|
155
|
+
) -> Response:
|
|
156
|
+
"""First-parent Revisions after `since`, oldest first; continue with `next_since`."""
|
|
157
|
+
domain = await open_domain(session, access)
|
|
158
|
+
ref = await open_branch(session, access, domain, branch)
|
|
159
|
+
bounds = settings.feed.changes
|
|
160
|
+
effective = bounds.default_limit if limit is None else limit
|
|
161
|
+
if effective > bounds.max_limit:
|
|
162
|
+
raise invalid("/query/limit", f"must not exceed {bounds.max_limit}", "limit_exceeded")
|
|
163
|
+
since_row = None if since is None else await open_revision(session, access, domain, since)
|
|
164
|
+
wanted = kind_filter(await branch_schema(session, runtime, domain.id, ref.name), kinds)
|
|
165
|
+
|
|
166
|
+
async def produce() -> ChangesFeedPage:
|
|
167
|
+
return await changes_since(
|
|
168
|
+
session,
|
|
169
|
+
runtime,
|
|
170
|
+
domain.id,
|
|
171
|
+
ref.name,
|
|
172
|
+
ref.head,
|
|
173
|
+
since_row,
|
|
174
|
+
limit=effective,
|
|
175
|
+
wanted=wanted,
|
|
176
|
+
settings=settings.feed,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return await respond(
|
|
180
|
+
request,
|
|
181
|
+
runtime.cache,
|
|
182
|
+
domain_id=domain.id,
|
|
183
|
+
revision=ref.head.hex(),
|
|
184
|
+
produce=produce,
|
|
185
|
+
headers={REVISION_HEADER: ref.head.hex()},
|
|
186
|
+
variant="" if since_row is None else since_row.id.hex(),
|
|
187
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Health reports (design 14): how sound the data of a Domain, or of one Kind, is.
|
|
2
|
+
|
|
3
|
+
Computed on request from the materialized head of a branch, or with `at` from a snapshot of an
|
|
4
|
+
earlier Revision (`reading.snapshots`), and kept in the response cache under the Revision they
|
|
5
|
+
describe, so asking again costs nothing until the branch moves. Callers with
|
|
6
|
+
Kind-narrowed grants get a report limited to those Kinds. `format=markdown` renders the report
|
|
7
|
+
for pasting into a prompt.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Annotated, Any, Literal
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, Query, Request, Response
|
|
15
|
+
|
|
16
|
+
from lorebase_keeper.agents.context import JSON, MARKDOWN
|
|
17
|
+
from lorebase_keeper.errors import Problem, ValidationProblem
|
|
18
|
+
from lorebase_keeper.kindhealth import markdown, report
|
|
19
|
+
from lorebase_keeper.kindhealth.models import DomainHealthOut, KindHealthOut
|
|
20
|
+
from lorebase_keeper.reading.caching import cache_key, respond_with
|
|
21
|
+
from lorebase_keeper.reading.points import REVISION_HEADER, read_guard, resolve_point
|
|
22
|
+
from lorebase_keeper.reading.records import require_kind
|
|
23
|
+
from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep
|
|
24
|
+
from lorebase_keeper.reading.snapshots import with_rows
|
|
25
|
+
from lorebase_keeper.reading.views import served
|
|
26
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
27
|
+
|
|
28
|
+
router = APIRouter(tags=["health"])
|
|
29
|
+
|
|
30
|
+
BASE = "/domains/{domain_id}"
|
|
31
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
32
|
+
403: {"model": Problem, "description": "The caller lacks data.read for the branch."},
|
|
33
|
+
404: {"model": Problem, "description": "Domain, branch or Kind not found."},
|
|
34
|
+
422: {"model": ValidationProblem, "description": "Invalid request."},
|
|
35
|
+
503: {"model": Problem, "description": "The scan exceeded its statement timeout."},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
DataRead = Annotated[ReadGuard, Depends(read_guard())]
|
|
39
|
+
Branch = Annotated[
|
|
40
|
+
str | None, Query(description="Branch to examine; default: the Domain's default branch.")
|
|
41
|
+
]
|
|
42
|
+
At = Annotated[
|
|
43
|
+
str | None,
|
|
44
|
+
Query(description="Revision id or tag to examine (time travel, served from a snapshot)."),
|
|
45
|
+
]
|
|
46
|
+
Format = Annotated[
|
|
47
|
+
Literal["json", "markdown"], Query(description="`markdown` renders the report as text.")
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get(
|
|
52
|
+
f"{BASE}/health",
|
|
53
|
+
summary="Health of a Domain",
|
|
54
|
+
responses={
|
|
55
|
+
**_ERRORS,
|
|
56
|
+
200: {
|
|
57
|
+
"model": DomainHealthOut,
|
|
58
|
+
"content": {JSON: {}, MARKDOWN: {}},
|
|
59
|
+
"description": "The report in JSON, or markdown with `format=markdown`.",
|
|
60
|
+
},
|
|
61
|
+
304: {"description": "Not modified: If-None-Match names the current ETag."},
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
async def domain_health(
|
|
65
|
+
domain_id: str,
|
|
66
|
+
access: DataRead,
|
|
67
|
+
runtime: ReadRuntimeDep,
|
|
68
|
+
session: ReadSessionDep,
|
|
69
|
+
request: Request,
|
|
70
|
+
branch: Branch = None,
|
|
71
|
+
at: At = None,
|
|
72
|
+
format: Format = "json",
|
|
73
|
+
) -> Response:
|
|
74
|
+
"""Per Kind: Records, Links by Relation, constraint violations against the current schema,
|
|
75
|
+
required-relation gaps, orphan Links, duplicates on unique and title Attributes, stale
|
|
76
|
+
derived values and the last change; for the Kinds the caller may read."""
|
|
77
|
+
point = await resolve_point(session, runtime, access, branch=branch, at=at)
|
|
78
|
+
|
|
79
|
+
async def produce() -> tuple[bytes, str]:
|
|
80
|
+
rows = await with_rows(session, runtime, point, "Health reports")
|
|
81
|
+
found = await report.scan_branch(
|
|
82
|
+
session,
|
|
83
|
+
rows,
|
|
84
|
+
runtime.read.health,
|
|
85
|
+
report.visible_kinds(point),
|
|
86
|
+
report.visible_relations(point),
|
|
87
|
+
)
|
|
88
|
+
out = DomainHealthOut(
|
|
89
|
+
**served(point),
|
|
90
|
+
domain=point.domain.id,
|
|
91
|
+
branch=point.branch,
|
|
92
|
+
summary=found.summary,
|
|
93
|
+
kinds=found.kinds,
|
|
94
|
+
relations=found.relations,
|
|
95
|
+
)
|
|
96
|
+
if format == "markdown":
|
|
97
|
+
return markdown.domain_markdown(out).encode(), MARKDOWN
|
|
98
|
+
return out.model_dump_json().encode(), JSON
|
|
99
|
+
|
|
100
|
+
return await respond_with(
|
|
101
|
+
request,
|
|
102
|
+
runtime.cache,
|
|
103
|
+
cache_key(request, point.domain.id, point.revision_hex, point.variant),
|
|
104
|
+
produce,
|
|
105
|
+
time_travel=not point.materialized,
|
|
106
|
+
headers={REVISION_HEADER: point.revision_hex},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get(
|
|
111
|
+
f"{BASE}/kinds/{{kind}}/health",
|
|
112
|
+
summary="Health of a Kind",
|
|
113
|
+
responses={
|
|
114
|
+
**_ERRORS,
|
|
115
|
+
200: {
|
|
116
|
+
"model": KindHealthOut,
|
|
117
|
+
"content": {JSON: {}, MARKDOWN: {}},
|
|
118
|
+
"description": "The report in JSON, or markdown with `format=markdown`.",
|
|
119
|
+
},
|
|
120
|
+
304: {"description": "Not modified: If-None-Match names the current ETag."},
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
async def kind_health(
|
|
124
|
+
domain_id: str,
|
|
125
|
+
kind: str,
|
|
126
|
+
access: DataRead,
|
|
127
|
+
runtime: ReadRuntimeDep,
|
|
128
|
+
session: ReadSessionDep,
|
|
129
|
+
request: Request,
|
|
130
|
+
branch: Branch = None,
|
|
131
|
+
at: At = None,
|
|
132
|
+
format: Format = "json",
|
|
133
|
+
) -> Response:
|
|
134
|
+
"""The checks of the Domain report for one Kind, with the Relations it takes part in."""
|
|
135
|
+
point = await resolve_point(session, runtime, access, branch=branch, at=at)
|
|
136
|
+
found_kind = require_kind(point, kind)
|
|
137
|
+
|
|
138
|
+
async def produce() -> tuple[bytes, str]:
|
|
139
|
+
rows = await with_rows(session, runtime, point, "Health reports")
|
|
140
|
+
found = await report.scan_branch(
|
|
141
|
+
session,
|
|
142
|
+
rows,
|
|
143
|
+
runtime.read.health,
|
|
144
|
+
[found_kind],
|
|
145
|
+
report.visible_relations(point, found_kind),
|
|
146
|
+
)
|
|
147
|
+
out = KindHealthOut(
|
|
148
|
+
**served(point),
|
|
149
|
+
domain=point.domain.id,
|
|
150
|
+
branch=point.branch,
|
|
151
|
+
summary=found.summary,
|
|
152
|
+
health=found.kinds[0],
|
|
153
|
+
relations=report.with_direction(found.relations, found_kind),
|
|
154
|
+
)
|
|
155
|
+
if format == "markdown":
|
|
156
|
+
return markdown.kind_markdown(out).encode(), MARKDOWN
|
|
157
|
+
return out.model_dump_json().encode(), JSON
|
|
158
|
+
|
|
159
|
+
return await respond_with(
|
|
160
|
+
request,
|
|
161
|
+
runtime.cache,
|
|
162
|
+
cache_key(request, point.domain.id, point.revision_hex, point.variant),
|
|
163
|
+
produce,
|
|
164
|
+
time_travel=not point.materialized,
|
|
165
|
+
headers={REVISION_HEADER: point.revision_hex},
|
|
166
|
+
)
|