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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,232 @@
1
+ """Preview of the expression mappings of a candidate Source (design 5.9, 11.1).
2
+
3
+ | Route | Permission |
4
+ |-------|------------|
5
+ | `POST /domains/{d}/sources:preview` | `schema.read` on the branch or the Source's target Kind |
6
+
7
+ The body is a schema change set, as for `POST /schema:validate` (`yaml`, or `patch` and
8
+ `remove`; an empty `patch` previews the schema as saved), `source`, the api name of the Source in
9
+ the schema it produces, and `rows`, sample rows of the Source as the caller read them (column
10
+ name to value). The schema is validated like a schema change (`schema.expression_limits`
11
+ included), then every Attribute mapped by `expression` is evaluated on each row, as Scribe
12
+ evaluates it when it maps rows, and the value before the mapping's transforms is returned.
13
+ Expressions are compiled once over every column the rows have: a column a row lacks is null.
14
+
15
+ Rows come from the caller and nothing else is read: no Record, no connection, no secret. At
16
+ most `read.source_preview_max_rows` rows of at most `read.source_preview_max_columns` columns
17
+ are accepted, and at most `read.source_preview_max_cells` expressions (rows times Attributes
18
+ mapped by an expression) are evaluated, off the event loop. A grant of `schema.read` narrowed
19
+ to Kinds is enough when it names the Source's target Kind (in the candidate and, when the Kind
20
+ exists there, in the schema read); otherwise the preview is refused (403) before the
21
+ candidate is validated.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Awaitable, Callable
27
+ from functools import partial
28
+ from typing import Annotated, Any
29
+
30
+ import anyio
31
+ from fastapi import APIRouter, Body, Depends, Request
32
+ from pydantic import BaseModel, Field, JsonValue
33
+
34
+ from lorebase_core.model import Schema, Source
35
+ from lorebase_keeper.auth.audit import annotate
36
+ from lorebase_keeper.auth.deps import AuthDep, PrincipalDep
37
+ from lorebase_keeper.auth.permissions import Permission
38
+ from lorebase_keeper.context import StoreDep
39
+ from lorebase_keeper.deps import KeeperSettingsDep
40
+ from lorebase_keeper.errors import Problem, ValidationProblem
41
+ from lorebase_keeper.reading.points import ReadPoint, resolve_point
42
+ from lorebase_keeper.reading.problems import invalid
43
+ from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep
44
+ from lorebase_keeper.reading.settings import ReadSettings
45
+ from lorebase_keeper.routes.derivations import PreviewErrorOut
46
+ from lorebase_keeper.routes.schema import ChangeSetBody
47
+ from lorebase_keeper.services import source_preview as service
48
+ from lorebase_keeper.services.derivation_preview import CandidateInvalidError
49
+ from lorebase_keeper.services.errors import violations_problem
50
+ from lorebase_keeper.services.reads import ReadGuard
51
+
52
+ router = APIRouter(tags=["sources"])
53
+
54
+ _ERRORS: dict[int | str, dict[str, Any]] = {
55
+ 403: {
56
+ "model": Problem,
57
+ "description": "The caller lacks schema.read for the branch and for the Source's "
58
+ "target Kind.",
59
+ },
60
+ 404: {"model": Problem, "description": "Domain, branch or Revision not found."},
61
+ 422: {
62
+ "model": ValidationProblem,
63
+ "description": "Invalid request, or a candidate schema that fails validation (`issues` "
64
+ "locate why).",
65
+ },
66
+ }
67
+
68
+
69
+ class SourcePreviewRequest(ChangeSetBody):
70
+ """A candidate schema change set, the Source to preview in it and sample rows."""
71
+
72
+ source: str = Field(
73
+ min_length=1, description="Api name of the Source in the schema the change set produces."
74
+ )
75
+ rows: list[dict[str, JsonValue]] = Field(
76
+ min_length=1,
77
+ description="Sample rows: column name to value (text for CSV, native values for JSON). "
78
+ "At most `read.source_preview_max_rows`, each with at most "
79
+ "`read.source_preview_max_columns` columns.",
80
+ )
81
+ branch: str | None = Field(default=None, description="Default: the default branch.")
82
+ at: str | None = Field(default=None, description="Revision id, tag or branch name.")
83
+
84
+
85
+ class ExpressionCellOut(BaseModel):
86
+ """The value of one expression mapping for one row, before the transforms."""
87
+
88
+ value: JsonValue = Field(description="Null when there is none or `error` is set.")
89
+ error: PreviewErrorOut | None = None
90
+
91
+
92
+ class SourcePreviewOut(BaseModel):
93
+ """Values of the expression mappings of a candidate Source on sample rows."""
94
+
95
+ domain: str
96
+ branch: str | None
97
+ revision: str = Field(description="Revision whose schema the change set was applied to.")
98
+ source: str
99
+ target_kind: str
100
+ attributes: list[str] = Field(description="Attributes mapped by an expression, by api name.")
101
+ rows: list[dict[str, ExpressionCellOut]] = Field(
102
+ description="One entry per row sent: the cell of each Attribute in `attributes`."
103
+ )
104
+
105
+
106
+ def schema_reader() -> Callable[..., Awaitable[ReadGuard]]:
107
+ """Dependency: the authenticated reader of the Domain named by the path; `schema.read` is
108
+ checked once the read point is resolved."""
109
+
110
+ async def dependency(
111
+ request: Request, principal: PrincipalDep, auth: AuthDep, keeper: KeeperSettingsDep
112
+ ) -> ReadGuard:
113
+ domain_id = str(request.path_params["domain_id"])
114
+ annotate(request, domain_id=domain_id, permission=Permission.SCHEMA_READ.value)
115
+ return ReadGuard(
116
+ authorizer=auth.authorizer,
117
+ principal=principal,
118
+ permissions=(Permission.SCHEMA_READ,),
119
+ domain_id=domain_id,
120
+ max_depth=keeper.reads.reachability_max_depth,
121
+ )
122
+
123
+ return dependency
124
+
125
+
126
+ SchemaReader = Annotated[ReadGuard, Depends(schema_reader())]
127
+
128
+
129
+ def _bound_rows(rows: list[dict[str, JsonValue]], expressions: int, read: ReadSettings) -> None:
130
+ """422 for more rows, columns or cells than the settings allow."""
131
+ if len(rows) > read.source_preview_max_rows:
132
+ raise invalid("/rows", f"must not exceed {read.source_preview_max_rows}", "limit_exceeded")
133
+ for index, row in enumerate(rows):
134
+ if len(row) > read.source_preview_max_columns:
135
+ raise invalid(
136
+ f"/rows/{index}",
137
+ f"must not have more than {read.source_preview_max_columns} columns",
138
+ "limit_exceeded",
139
+ )
140
+ if len(rows) * expressions > read.source_preview_max_cells:
141
+ raise invalid(
142
+ "/rows",
143
+ f"{len(rows)} rows times {expressions} expression mappings exceed "
144
+ f"{read.source_preview_max_cells} cells",
145
+ "limit_exceeded",
146
+ )
147
+
148
+
149
+ def _target_kind_check(point: ReadPoint) -> Callable[[Schema, Source | None], None]:
150
+ """403 unless the caller may read the Source's target Kind: always with grants that are
151
+ not narrowed to Kinds; with Kind-narrowed grants, when they name it in the candidate and,
152
+ if the Kind exists there, in the schema read (a rename in the change set moves nothing)."""
153
+
154
+ def check(candidate: Schema, source: Source | None) -> None:
155
+ if point.scope.kinds is None:
156
+ return
157
+ kind = None if source is None else candidate.kinds.get(source.target_kind)
158
+ if kind is None:
159
+ raise point.scope.guard.denied()
160
+ names = {kind.api_name}
161
+ stored = point.schema.kinds.get(kind.id)
162
+ if stored is not None:
163
+ names.add(stored.api_name)
164
+ if point.scope.invisible and not all(point.scope.allows(n) for n in names):
165
+ # Like a Source that does not exist: nothing tells which Kind is missing.
166
+ raise point.scope.guard.denied()
167
+ point.scope.require(names)
168
+
169
+ return check
170
+
171
+
172
+ @router.post(
173
+ "/domains/{domain_id}/sources:preview",
174
+ summary="Preview the expression mappings of a candidate Source",
175
+ response_model=SourcePreviewOut,
176
+ responses=_ERRORS,
177
+ )
178
+ async def preview_source(
179
+ domain_id: str,
180
+ payload: Annotated[SourcePreviewRequest, Body()],
181
+ access: SchemaReader,
182
+ store: StoreDep,
183
+ keeper: KeeperSettingsDep,
184
+ runtime: ReadRuntimeDep,
185
+ session: ReadSessionDep,
186
+ ) -> SourcePreviewOut:
187
+ """Evaluate the `expression` mappings of a Source, as edited and not yet saved, on sample
188
+ rows. Nothing is read from the Source and nothing is written."""
189
+ _bound_rows(payload.rows, 0, runtime.read)
190
+ point = await resolve_point(session, runtime, access, branch=payload.branch, at=payload.at)
191
+ try:
192
+ candidate = service.candidate_of(
193
+ point.schema,
194
+ payload.to_input(),
195
+ payload.source,
196
+ store.runtime.schema.rules(),
197
+ authorize=_target_kind_check(point),
198
+ )
199
+ except CandidateInvalidError as error:
200
+ raise violations_problem(error.issues, limit=keeper.schema_changes.max_issues) from None
201
+ attributes = service.expression_attributes(candidate)
202
+ _bound_rows(payload.rows, len(attributes), runtime.read)
203
+ cells = await anyio.to_thread.run_sync(
204
+ partial(
205
+ service.evaluate,
206
+ candidate,
207
+ payload.rows,
208
+ limits=store.runtime.schema.expression_limits,
209
+ now=store.clock(),
210
+ )
211
+ )
212
+ kind = candidate.schema.kinds[candidate.source.target_kind]
213
+ return SourcePreviewOut(
214
+ domain=domain_id,
215
+ branch=point.branch,
216
+ revision=point.revision_hex,
217
+ source=payload.source,
218
+ target_kind=kind.api_name,
219
+ attributes=list(attributes),
220
+ rows=[
221
+ {
222
+ attribute: ExpressionCellOut(
223
+ value=cell.value,
224
+ error=None
225
+ if cell.error_code is None
226
+ else PreviewErrorOut(code=cell.error_code, message=cell.error_message),
227
+ )
228
+ for attribute, cell in row.items()
229
+ }
230
+ for row in cells
231
+ ],
232
+ )
@@ -0,0 +1,200 @@
1
+ """Structured Sources of a Domain (design 9.1): trigger a synchronization, list its runs.
2
+
3
+ `POST /domains/{d}/sources/{s}/sync` queues a `source_sync` job that Scribe runs (permission
4
+ `source.sync`: `editor`, `maintainer`, `admin`) and answers 202 with the job id; while an
5
+ identical job is queued or running, that job is returned instead of a new one. The Source is
6
+ named by id or api name and looked up in the schema of the Domain's default branch. Every
7
+ trigger is audited (`source.sync`). Triggering a Source that maps vault Attributes also needs
8
+ `pii.import` on the whole Domain (`services.pii_import`): a caller who could feed or re-run
9
+ such a Source would learn whether a value was erased.
10
+
11
+ `GET /domains/{d}/sources/{s}/runs` lists the runs recorded by Scribe, newest first, with their
12
+ counts, Revision, Proposal and error. It needs `data.read` through a Domain-wide grant: run
13
+ stats cover every Kind the Source writes and errors quote row values, so callers whose grants
14
+ are narrowed to a Kind or a branch get 403, as for unknown Domains they may not be told about.
15
+ Errors are shown only to callers cleared Domain-wide for every sensitivity tag (they may
16
+ quote sensitive values of any branch's schema); others get `{"redacted": true}`.
17
+ The trigger needs a Domain-wide `source.sync` grant and answers with the job only, never run
18
+ data.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from datetime import datetime
24
+ from typing import Annotated, Any
25
+
26
+ from fastapi import APIRouter, Depends, Request, Response
27
+ from pydantic import BaseModel, ConfigDict, Field
28
+ from sqlalchemy.ext.asyncio import AsyncSession
29
+
30
+ from lorebase_core.model import Schema, Sensitivity, Source
31
+ from lorebase_keeper.auth.audit import annotate
32
+ from lorebase_keeper.auth.deps import Access, AuthDep, require
33
+ from lorebase_keeper.auth.permissions import Permission
34
+ from lorebase_keeper.context import StoreDep, require_domain
35
+ from lorebase_keeper.errors import NOT_FOUND, VALIDATION_FAILED, Problem, ProblemError
36
+ from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
37
+ from lorebase_keeper.reading.points import open_domain, read_guard
38
+ from lorebase_keeper.reading.sensitivity import clearance_of
39
+ from lorebase_keeper.services.pii_import import require_pii_import, source_vault_targets
40
+ from lorebase_keeper.services.reads import ReadGuard
41
+ from lorebase_store.commit.schema import load_compiled
42
+ from lorebase_store.jobs import SOURCE_SYNC_JOB, enqueue_job, source_sync_payload
43
+ from lorebase_store.repositories import (
44
+ MaterializedRepository,
45
+ SourceRunRepository,
46
+ SourceRunRow,
47
+ )
48
+ from lorebase_store.runtime import StoreRuntime
49
+
50
+ router = APIRouter(tags=["sources"])
51
+
52
+ BASE = "/domains/{domain_id}/sources/{source}"
53
+ _ERRORS: dict[int | str, dict[str, Any]] = {
54
+ 403: {"model": Problem, "description": "The caller lacks the permission."},
55
+ 404: {"model": Problem, "description": "Domain or Source not found."},
56
+ }
57
+ _SYNC_ERRORS: dict[int | str, dict[str, Any]] = {
58
+ **_ERRORS,
59
+ 403: {
60
+ "model": Problem,
61
+ "description": "The caller lacks source.sync, or pii.import for a Source mapping "
62
+ "vault Attributes.",
63
+ },
64
+ }
65
+
66
+ Sync = Annotated[Access, Depends(require(Permission.SOURCE_SYNC))]
67
+ ReadRuns = Annotated[ReadGuard, Depends(read_guard())]
68
+
69
+
70
+ class SyncRequest(BaseModel):
71
+ model_config = ConfigDict(extra="forbid")
72
+
73
+ dry_run: bool = Field(
74
+ default=False, description="Compute and record the delta without writing to any branch."
75
+ )
76
+
77
+
78
+ class SyncQueued(BaseModel):
79
+ job_id: int = Field(description="Id of the queued (or already pending) job.")
80
+ source: str = Field(description="Source id.")
81
+ api_name: str
82
+ dry_run: bool
83
+
84
+
85
+ class SourceRunOut(BaseModel):
86
+ id: str
87
+ source: str
88
+ status: str
89
+ started_at: datetime | None = None
90
+ finished_at: datetime | None = None
91
+ stats: dict[str, Any]
92
+ revision: str | None = None
93
+ proposal_id: str | None = None
94
+ error: dict[str, Any] | None = None
95
+
96
+
97
+ async def _source(
98
+ session: AsyncSession, domain_id: str, branch: str, reference: str, runtime: StoreRuntime
99
+ ) -> tuple[Schema, Source]:
100
+ """The Source named by id or api name in the schema of `branch`, with that schema."""
101
+ compiled = await MaterializedRepository(
102
+ session, domain_id, branch, writes=runtime.settings.writes
103
+ ).schema()
104
+ schema = None if compiled is None else load_compiled(compiled.compiled)
105
+ found = None
106
+ if schema is not None:
107
+ found = schema.sources.get(reference) or schema.source_by_api_name(reference)
108
+ if schema is None or found is None:
109
+ raise ProblemError(NOT_FOUND, f"Source {reference!r} not found.")
110
+ return schema, found
111
+
112
+
113
+ REDACTED_ERROR: dict[str, Any] = {"redacted": True}
114
+
115
+
116
+ def _run(row: SourceRunRow, *, errors: bool) -> SourceRunOut:
117
+ return SourceRunOut(
118
+ id=row.id,
119
+ source=row.source_id,
120
+ status=row.status.value,
121
+ started_at=row.started_at,
122
+ finished_at=row.finished_at,
123
+ stats=row.stats,
124
+ revision=None if row.revision_id is None else row.revision_id.hex(),
125
+ proposal_id=row.proposal_id,
126
+ error=row.error if errors or row.error is None else REDACTED_ERROR,
127
+ )
128
+
129
+
130
+ @router.post(
131
+ f"{BASE}/sync",
132
+ status_code=202,
133
+ summary="Synchronize a Source",
134
+ response_model=SyncQueued,
135
+ responses=_SYNC_ERRORS,
136
+ )
137
+ async def sync_source(
138
+ domain_id: str,
139
+ source: str,
140
+ access: Sync,
141
+ auth: AuthDep,
142
+ store: StoreDep,
143
+ request: Request,
144
+ response: Response,
145
+ body: SyncRequest | None = None,
146
+ ) -> SyncQueued:
147
+ """Queue a synchronization of the Source for Scribe."""
148
+ dry_run = body is not None and body.dry_run
149
+ async with store.sessions() as session, session.begin():
150
+ domain = await require_domain(session, domain_id)
151
+ schema, found = await _source(
152
+ session, domain_id, domain.default_branch, source, store.runtime
153
+ )
154
+ if source_vault_targets(schema, found):
155
+ await require_pii_import(auth.authorizer, access.principal, domain_id)
156
+ job_id = await enqueue_job(
157
+ session,
158
+ SOURCE_SYNC_JOB,
159
+ domain_id=domain_id,
160
+ payload=source_sync_payload(found.id, dry_run=dry_run),
161
+ unique=True,
162
+ )
163
+ annotate(request, action="source.sync", target=found.id, job=job_id, dry_run=dry_run)
164
+ response.headers["Location"] = f"{request.url.path.rsplit('/', 1)[0]}/runs"
165
+ return SyncQueued(job_id=job_id, source=found.id, api_name=found.api_name, dry_run=dry_run)
166
+
167
+
168
+ @router.get(f"{BASE}/runs", summary="List Source runs", responses=_ERRORS)
169
+ async def list_source_runs(
170
+ domain_id: str, source: str, access: ReadRuns, store: StoreDep, params: PageParamsDep
171
+ ) -> Page[SourceRunOut]:
172
+ """Runs of the Source, newest first."""
173
+ after = None
174
+ if params.position is not None:
175
+ try:
176
+ after = (
177
+ datetime.fromisoformat(str(params.position["at"])),
178
+ str(params.position["id"]),
179
+ )
180
+ except (KeyError, ValueError):
181
+ raise ProblemError(VALIDATION_FAILED, "The cursor is not a run cursor.") from None
182
+ async with store.sessions() as session, session.begin():
183
+ domain = await open_domain(session, access)
184
+ await access.require_wide()
185
+ _, found = await _source(session, domain.id, domain.default_branch, source, store.runtime)
186
+ rows = await SourceRunRepository(session, domain_id).page(
187
+ found.id, limit=params.limit + 1, after=after
188
+ )
189
+ clearance = await clearance_of(access.authorizer, access.principal, domain_id, None)
190
+ errors = clearance.covers(Sensitivity, ())
191
+ more = len(rows) > params.limit
192
+ rows = rows[: params.limit]
193
+ last = rows[-1] if rows else None
194
+ next_cursor = (
195
+ encode_cursor({"at": last.started_at.isoformat(), "id": last.id})
196
+ if more and last is not None and last.started_at is not None
197
+ else None
198
+ )
199
+ items = [_run(r, errors=errors) for r in rows]
200
+ return Page[SourceRunOut](items=items, next_cursor=next_cursor)