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,392 @@
1
+ """Operation execution (design 5.7, 10.1, 11.1): `POST /domains/{d}/operations/{op}/execute`.
2
+
3
+ Built-in Operations need `operation.execute` (role `editor`), stored ones
4
+ `operation.execute_custom` (role `operator`) and, when the Operation lists `permissions`, one
5
+ of the principals or groups named there. Grants narrowed to a Kind, Operation or branch
6
+ pattern apply. The response carries `status` (`committed` or `dry_run`), the computed `edits`,
7
+ `violations` and the new Revision(s); rejected executions are 422 problems with the same
8
+ members (`precondition-failed` when a CEL precondition does not hold), and executions that
9
+ need review (on a protected branch, or when a review policy expression is true) are 409
10
+ problems pointing to Proposals.
11
+
12
+ An agent that names a `task` gets a Proposal instead of that 409 (`services.agent_writes`): the
13
+ Operation runs on the branch `agent/{principal}/{task}` and the answer is 202 with the
14
+ Proposal and where it stands (`proposed`, `merged` or `conflicts`), `next` hints included.
15
+ `intent` goes into the Proposal description; `auto_propose` and `auto_merge` override the
16
+ defaults of `keeper.agent_writes` (merging only if the Domain allows it for the caller's role).
17
+ Requests of this kind are `writes` for rate limiting like any other POST.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import asdict
23
+ from typing import Any, Literal
24
+
25
+ from fastapi import APIRouter, Request, Response
26
+ from fastapi.responses import JSONResponse
27
+ from pydantic import BaseModel, Field, JsonValue
28
+ from sqlalchemy.ext.asyncio import AsyncSession
29
+
30
+ from lorebase_core.canonical import hex
31
+ from lorebase_keeper.auth.audit import annotate
32
+ from lorebase_keeper.auth.deps import AuthDep, PrincipalDep, authorize
33
+ from lorebase_keeper.auth.permissions import Permission
34
+ from lorebase_keeper.auth.rbac import AccessContext
35
+ from lorebase_keeper.context import StoreDep, parse_revision_id
36
+ from lorebase_keeper.deps import IdempotencyKeyDep, KeeperSettingsDep, get_idempotency_key
37
+ from lorebase_keeper.errors import VALIDATION_FAILED, Problem, ProblemError
38
+ from lorebase_keeper.reading.sensitivity import Masking, default_schema, masking_for
39
+ from lorebase_keeper.routes.domains import REPLAYED_HEADER
40
+ from lorebase_keeper.services import agent_writes
41
+ from lorebase_keeper.services import operations as service
42
+ from lorebase_keeper.services.errors import BRANCH_BUSY, commit_problem, finding_documents
43
+ from lorebase_store.auth import NewAuditEntry
44
+ from lorebase_store.commit import CommitError
45
+ from lorebase_store.repositories import LockTimeoutError
46
+
47
+ router = APIRouter(tags=["operations"])
48
+
49
+
50
+ class ExecuteRequest(BaseModel):
51
+ """An execution of an Operation."""
52
+
53
+ parameters: dict[str, JsonValue] = Field(default_factory=dict)
54
+ branch: str | None = Field(default=None, description="Default: the default branch.")
55
+ dry_run: bool = Field(
56
+ default=False, description="Compute edits and violations without committing."
57
+ )
58
+ idempotency_key: str | None = Field(
59
+ default=None,
60
+ description="Same as the Idempotency-Key header; a retry with the same key and "
61
+ "parameters returns the original result.",
62
+ )
63
+ expected_head: str | None = Field(
64
+ default=None, description="Fail with 409 unless the branch head is this Revision."
65
+ )
66
+ message: str | None = Field(default=None, max_length=2000)
67
+ task: str | None = Field(
68
+ default=None,
69
+ min_length=1,
70
+ max_length=200,
71
+ description="Short slug naming the piece of work. When the Operation needs review "
72
+ "(protected branch, review policy), Keeper runs it on the branch "
73
+ "`agent/{principal}/{task}` and opens or updates a Proposal; without a task the "
74
+ "refusal is the 409 `review-required`. Ignored when the Operation commits directly.",
75
+ )
76
+ intent: str | None = Field(
77
+ default=None,
78
+ max_length=agent_writes.INTENT_MAX_CHARS,
79
+ description="What the agent is after, in a sentence or two; becomes the start of the "
80
+ "Proposal description.",
81
+ )
82
+ auto_propose: bool | None = Field(
83
+ default=None,
84
+ description="Open a Proposal when review is needed. Default: "
85
+ "`keeper.agent_writes.auto_propose` for agent principals, false for others. `true` "
86
+ "needs a `task`.",
87
+ )
88
+ auto_merge: bool | None = Field(
89
+ default=None,
90
+ description="Merge the Proposal at once. Default: `keeper.agent_writes.auto_merge`. "
91
+ "Honoured only when the Domain allows it for the caller's role, there is no open "
92
+ "Conflict and the required approvals are in.",
93
+ )
94
+
95
+
96
+ class ExecutionOut(BaseModel):
97
+ """Result of an execution."""
98
+
99
+ status: Literal["committed", "dry_run"]
100
+ operation: str
101
+ branch: str
102
+ invocation_id: str
103
+ parameters_hash: str
104
+ head: str = Field(description="Branch head the Operation ran against.")
105
+ revision: str | None = Field(description="Last Revision written; null for dry runs.")
106
+ revisions: list[str] = Field(description="Every Revision written (imports run in batches).")
107
+ edits: list[dict[str, Any]]
108
+ edit_count: int
109
+ edits_truncated: bool
110
+ violations: list[dict[str, Any]]
111
+ violation_count: int
112
+ warnings: list[dict[str, Any]]
113
+ review_required: bool = Field(
114
+ description="True when a non-dry run would need a Proposal on this branch."
115
+ )
116
+ replayed: bool
117
+
118
+
119
+ class ProposalRefOut(BaseModel):
120
+ """The Proposal carrying the edits of an agent write."""
121
+
122
+ id: str
123
+ title: str
124
+ status: str
125
+ source_ref: str
126
+ target_ref: str
127
+ url: str
128
+ created: bool = Field(description="False when an open Proposal of the branch was updated.")
129
+
130
+
131
+ class AgentApprovalOut(BaseModel):
132
+ required: int
133
+ approvals: int
134
+ satisfied: bool
135
+
136
+
137
+ class AgentAttemptOut(BaseModel):
138
+ """The merge attempt: Conflicts between the task branch and the target."""
139
+
140
+ id: str
141
+ result: str | None
142
+ open_conflicts: int
143
+ resolved_conflicts: int
144
+ counts: dict[str, dict[str, int]]
145
+
146
+
147
+ class AgentMergeOut(BaseModel):
148
+ requested: bool
149
+ allowed: bool = Field(description="The Domain allows automatic merges for the caller's role.")
150
+ performed: bool
151
+ blockers: list[str] = Field(
152
+ description="What keeps the Proposal from merging by itself: `approvals-pending` (the "
153
+ "target needs approvals), `review-policy-needs-approval` (the Operation's review "
154
+ "policy asks for a review and nobody approved)."
155
+ )
156
+
157
+
158
+ class NextHint(BaseModel):
159
+ """A next step, in the style of problem details extensions."""
160
+
161
+ action: str
162
+ detail: str
163
+ method: str | None = None
164
+ href: str | None = None
165
+ permission: str | None = None
166
+
167
+
168
+ class AgentWriteOut(BaseModel):
169
+ """An Operation that needed review, run on a task branch and proposed."""
170
+
171
+ status: Literal["proposed", "merged", "conflicts"]
172
+ operation: str
173
+ branch: str = Field(description="The task branch.")
174
+ target: str = Field(description="The branch the Proposal merges into.")
175
+ invocation_id: str
176
+ parameters_hash: str
177
+ revision: str | None = Field(description="Last Revision written on the task branch.")
178
+ revisions: list[str]
179
+ edit_count: int
180
+ replayed: bool
181
+ proposal: ProposalRefOut
182
+ approval: AgentApprovalOut
183
+ attempt: AgentAttemptOut | None
184
+ merge: AgentMergeOut
185
+ merged_revision: str | None = Field(description="Head of the target after an automatic merge.")
186
+ branch_created: bool
187
+ branch_deleted: bool
188
+ warnings: list[dict[str, Any]]
189
+ next: list[NextHint]
190
+
191
+
192
+ _ERRORS: dict[int | str, dict[str, Any]] = {
193
+ 403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
194
+ 404: {"model": Problem, "description": "Domain, branch or Operation not found."},
195
+ 202: {
196
+ "model": AgentWriteOut,
197
+ "description": "The Operation needs review and ran on a task branch; a Proposal "
198
+ "carries the edits (`status`: `proposed`, `merged` or `conflicts`).",
199
+ },
200
+ 409: {
201
+ "model": Problem,
202
+ "description": "Review required (`review-required`, with `next` hints), head moved "
203
+ "(`stale-head`), idempotency key reused with other parameters (`idempotency-conflict`) "
204
+ "or too many task branches (`agent-branch-limit`).",
205
+ },
206
+ 422: {
207
+ "model": Problem,
208
+ "description": "Invalid parameters or violations (`issues`, `edits`), a precondition "
209
+ "that does not hold (`precondition-failed`, with the expression and its position), or "
210
+ "expressions not enabled (`expressions-not-enabled`).",
211
+ },
212
+ 503: {"model": Problem, "description": "The branch is locked by another writer."},
213
+ }
214
+
215
+
216
+ def _key(header: str | None, body: str | None) -> str | None:
217
+ if body is not None:
218
+ get_idempotency_key(body)
219
+ if header is not None and body is not None and header != body:
220
+ raise ProblemError(
221
+ VALIDATION_FAILED,
222
+ "The Idempotency-Key header and idempotency_key differ.",
223
+ )
224
+ return header or body
225
+
226
+
227
+ @router.post(
228
+ "/domains/{domain_id}/operations/{operation}/execute",
229
+ summary="Execute an Operation",
230
+ response_model=ExecutionOut,
231
+ responses=_ERRORS,
232
+ )
233
+ async def execute_operation(
234
+ domain_id: str,
235
+ operation: str,
236
+ body: ExecuteRequest,
237
+ request: Request,
238
+ response: Response,
239
+ principal: PrincipalDep,
240
+ auth: AuthDep,
241
+ store: StoreDep,
242
+ keeper: KeeperSettingsDep,
243
+ header_key: IdempotencyKeyDep,
244
+ ) -> ExecutionOut | JSONResponse:
245
+ """Validate parameters, compute the edits and commit them as a Revision (or dry-run).
246
+
247
+ An Operation that needs review is a 409 `review-required` unless the request names a
248
+ `task`: then it runs on a task branch and the answer is 202 with the Proposal.
249
+ """
250
+ settings = keeper.operations
251
+ if body.auto_propose and body.task is None:
252
+ raise ProblemError(
253
+ VALIDATION_FAILED,
254
+ "One or more request members are invalid.",
255
+ extensions={
256
+ "errors": [
257
+ {
258
+ "pointer": "/body/task",
259
+ "message": "is required with auto_propose",
260
+ "code": "required",
261
+ }
262
+ ]
263
+ },
264
+ )
265
+
266
+ async def check(permission: Permission, context: AccessContext) -> None:
267
+ await authorize(request, auth, principal, permission, domain_id, context)
268
+
269
+ execution = service.ExecutionRequest(
270
+ operation=operation,
271
+ parameters=body.parameters,
272
+ branch=body.branch,
273
+ dry_run=body.dry_run,
274
+ idempotency_key=_key(header_key, body.idempotency_key),
275
+ expected_head=None
276
+ if body.expected_head is None
277
+ else parse_revision_id(body.expected_head, "/body/expected_head"),
278
+ message=body.message,
279
+ )
280
+
281
+ async def emit(action: str, target: str, **details: Any) -> None:
282
+ if auth.audit_writer is not None:
283
+ await auth.audit_writer.write(
284
+ NewAuditEntry(
285
+ actor=principal.id,
286
+ action=action,
287
+ domain_id=domain_id,
288
+ target=target,
289
+ details={"outcome": "success", "via": "agent_write", **details},
290
+ )
291
+ )
292
+
293
+ async def roles_of(context: AccessContext) -> frozenset[str]:
294
+ return await auth.authorizer.roles_of(principal, domain_id, context)
295
+
296
+ async def reveal(session: AsyncSession) -> Masking:
297
+ reference = await default_schema(session, domain_id, store.runtime)
298
+ return await masking_for(
299
+ auth.authorizer, principal, domain_id, reference, keeper.reads.sensitivity
300
+ )
301
+
302
+ outcome: service.ExecutionOutcome | agent_writes.AgentWriteOutcome
303
+ try:
304
+ try:
305
+ outcome = await service.execute(
306
+ store,
307
+ domain_id,
308
+ execution,
309
+ principal,
310
+ settings,
311
+ check=check,
312
+ proposals_path=f"{keeper.api_prefix}/domains/{domain_id}/proposals",
313
+ reveal=reveal,
314
+ )
315
+ except ProblemError as refusal:
316
+ if body.dry_run or not agent_writes.diverts(refusal):
317
+ raise
318
+ outcome = await agent_writes.run(
319
+ store,
320
+ domain_id,
321
+ execution,
322
+ agent_writes.AgentRequest(
323
+ task=body.task,
324
+ intent=body.intent,
325
+ auto_propose=body.auto_propose,
326
+ auto_merge=body.auto_merge,
327
+ ),
328
+ principal,
329
+ keeper,
330
+ original=refusal,
331
+ check=check,
332
+ roles_of=roles_of,
333
+ emit=emit,
334
+ )
335
+ except LockTimeoutError:
336
+ raise ProblemError(
337
+ BRANCH_BUSY,
338
+ "Another writer holds the branch; retry later.",
339
+ headers={"Retry-After": str(keeper.branches.lock_retry_after_s)},
340
+ ) from None
341
+ except CommitError as error:
342
+ raise commit_problem(
343
+ error,
344
+ max_issues=settings.max_issues,
345
+ retry_after_s=keeper.branches.lock_retry_after_s,
346
+ ) from None
347
+ if isinstance(outcome, agent_writes.AgentWriteOutcome):
348
+ annotate(
349
+ request,
350
+ action="operation.propose",
351
+ target=operation,
352
+ task=body.task,
353
+ **agent_writes.audit_details(outcome),
354
+ )
355
+ if outcome.replayed:
356
+ response.headers[REPLAYED_HEADER] = "true"
357
+ return JSONResponse(
358
+ status_code=202,
359
+ content=AgentWriteOut.model_validate(asdict(outcome)).model_dump(mode="json"),
360
+ headers=dict(response.headers),
361
+ )
362
+ revisions = [hex(r) for r in outcome.revisions]
363
+ annotate(
364
+ request,
365
+ action="operation.dry_run" if body.dry_run else "operation.execute",
366
+ target=operation,
367
+ branch=outcome.branch,
368
+ invocation_id=outcome.invocation_id,
369
+ parameters_hash=outcome.parameters_hash,
370
+ revisions=revisions,
371
+ replayed=outcome.replayed,
372
+ )
373
+ if outcome.replayed:
374
+ response.headers[REPLAYED_HEADER] = "true"
375
+ return ExecutionOut(
376
+ status=outcome.status.value,
377
+ operation=outcome.operation,
378
+ branch=outcome.branch,
379
+ invocation_id=outcome.invocation_id,
380
+ parameters_hash=outcome.parameters_hash,
381
+ head=hex(outcome.head),
382
+ revision=revisions[-1] if revisions else None,
383
+ revisions=revisions,
384
+ edits=outcome.edits,
385
+ edit_count=outcome.edit_count,
386
+ edits_truncated=len(outcome.edits) < outcome.edit_count,
387
+ violations=finding_documents(outcome.violations, settings.max_issues),
388
+ violation_count=len(outcome.violations),
389
+ warnings=finding_documents(outcome.warnings, settings.max_issues),
390
+ review_required=outcome.review_required,
391
+ replayed=outcome.replayed,
392
+ )