reactifact 0.6.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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- reactifact-0.6.0.dist-info/top_level.txt +1 -0
reactifact/context.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any, TypeVar, cast, overload
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from .artifacts import Artifact
|
|
12
|
+
from .checkpoints import CheckpointBackend, FileBackend, KVBackend
|
|
13
|
+
from .commit import Commit
|
|
14
|
+
from .commit_log import CommitLog
|
|
15
|
+
from .events import Event, EventType
|
|
16
|
+
from .interrupt import PendingQuestion
|
|
17
|
+
from .patches import (
|
|
18
|
+
Create,
|
|
19
|
+
Delete,
|
|
20
|
+
Relation,
|
|
21
|
+
Update,
|
|
22
|
+
)
|
|
23
|
+
from .relations import RelationGraph
|
|
24
|
+
from .resources import RuntimeResources
|
|
25
|
+
from .streaming import EventHub, ProgressEvent, QueueEvent
|
|
26
|
+
|
|
27
|
+
TData = TypeVar("TData", bound=BaseModel)
|
|
28
|
+
TArtifact = TypeVar("TArtifact", bound=BaseModel)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class View:
|
|
33
|
+
"""Context projection for an agent: artifact references + serialization (§27).
|
|
34
|
+
|
|
35
|
+
Used for building the prompt and controlling the token budget (§58):
|
|
36
|
+
`tokens_estimate` is a rough estimate (≈4 characters per token).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
artifacts: list[Artifact[Any]] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def render(self, *, max_chars: int | None = None) -> str:
|
|
42
|
+
"""Serializes artifacts into compact text for the prompt.
|
|
43
|
+
|
|
44
|
+
Each artifact is a line `[Type] {json}`. `max_chars` truncates the result.
|
|
45
|
+
"""
|
|
46
|
+
lines = [
|
|
47
|
+
f"[{type(a.data).__name__}] "
|
|
48
|
+
+ json.dumps(a.data.model_dump(mode="json"), ensure_ascii=False)
|
|
49
|
+
for a in self.artifacts
|
|
50
|
+
]
|
|
51
|
+
text = "\n".join(lines)
|
|
52
|
+
if max_chars is not None and len(text) > max_chars:
|
|
53
|
+
return text[:max_chars]
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def tokens_estimate(self) -> int:
|
|
58
|
+
"""Rough estimate of the prompt size in tokens (≈4 chars/token, §58)."""
|
|
59
|
+
return max(1, len(self.render()) // 4)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Context:
|
|
63
|
+
"""Central artifact store with an event queue.
|
|
64
|
+
|
|
65
|
+
Git-like model: every applied commit forms a new Context version (head).
|
|
66
|
+
Commits chain through parent_id and carry a reads/writes trace — the actual
|
|
67
|
+
agent linkage via consumes/produces.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, resources: RuntimeResources | None = None):
|
|
71
|
+
self._artifacts: dict[str, Artifact[Any]] = {}
|
|
72
|
+
self._events: list[Event] = []
|
|
73
|
+
self._log = CommitLog()
|
|
74
|
+
self.resources = resources or RuntimeResources()
|
|
75
|
+
self._hub = EventHub()
|
|
76
|
+
self._relations = RelationGraph()
|
|
77
|
+
self._base: Context | None = None
|
|
78
|
+
self._fork_name: str = ""
|
|
79
|
+
# Incrementally maintained (§ dependents index): artifact ids whose
|
|
80
|
+
# producing commit read a source that has since moved to a newer
|
|
81
|
+
# version. Kept up to date by `update()`/`log_commit()` so
|
|
82
|
+
# `stale_artifacts()`/`has_stale()` never rescan the whole context.
|
|
83
|
+
self._stale: set[str] = set()
|
|
84
|
+
|
|
85
|
+
# ---- announce: agent progress events streamed out ----
|
|
86
|
+
|
|
87
|
+
def announce(self, message: str, *, kind: str = "status", **data: Any) -> None:
|
|
88
|
+
"""Publishes a progress event to active streams (no-op without subscribers).
|
|
89
|
+
|
|
90
|
+
`kind` is a category for the application: "status" (domain agent statuses),
|
|
91
|
+
"agent" (internal, from framework producers like ToolUse).
|
|
92
|
+
The application itself decides which kinds to show the user.
|
|
93
|
+
"""
|
|
94
|
+
if self._hub.has_subscribers:
|
|
95
|
+
self._hub.publish(
|
|
96
|
+
ProgressEvent(
|
|
97
|
+
kind=kind,
|
|
98
|
+
message=message,
|
|
99
|
+
data=data,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def subscribe(self) -> QueueEvent:
|
|
104
|
+
return self._hub.subscribe()
|
|
105
|
+
|
|
106
|
+
def unsubscribe(self, queue: QueueEvent) -> None:
|
|
107
|
+
self._hub.unsubscribe(queue)
|
|
108
|
+
|
|
109
|
+
def create(self, data: TData, id: str | None = None) -> Artifact[TData]:
|
|
110
|
+
"""Creates a new artifact and generates an ARTIFACT_CREATED event.
|
|
111
|
+
|
|
112
|
+
If a stable id is given and an artifact with it already exists, returns
|
|
113
|
+
the existing one without creating a duplicate or an event (idempotency, §42).
|
|
114
|
+
"""
|
|
115
|
+
if id is not None and id in self._artifacts:
|
|
116
|
+
return self._artifacts[id]
|
|
117
|
+
artifact = Artifact(data=data, id=id)
|
|
118
|
+
self._artifacts[artifact.id] = artifact
|
|
119
|
+
self._events.append(
|
|
120
|
+
Event(
|
|
121
|
+
type=EventType.ARTIFACT_CREATED,
|
|
122
|
+
artifact_type=type(data),
|
|
123
|
+
artifact_id=artifact.id,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return artifact
|
|
127
|
+
|
|
128
|
+
def get(self, artifact_id: str) -> Artifact[Any] | None:
|
|
129
|
+
"""Returns the artifact by id or None."""
|
|
130
|
+
return self._artifacts.get(artifact_id)
|
|
131
|
+
|
|
132
|
+
def update(self, artifact_id: str, new_data: TData) -> Artifact[TData] | None:
|
|
133
|
+
"""Updates artifact data, creates a new version, generates ARTIFACT_UPDATED.
|
|
134
|
+
|
|
135
|
+
If the data did not change, the version and the event are left untouched:
|
|
136
|
+
no-op patches must not cascade into reactions (§41, §42).
|
|
137
|
+
"""
|
|
138
|
+
artifact = self._artifacts.get(artifact_id)
|
|
139
|
+
if artifact is None:
|
|
140
|
+
return None
|
|
141
|
+
if artifact.data == new_data:
|
|
142
|
+
return artifact
|
|
143
|
+
artifact.update(new_data)
|
|
144
|
+
self._events.append(
|
|
145
|
+
Event(
|
|
146
|
+
type=EventType.ARTIFACT_UPDATED,
|
|
147
|
+
artifact_type=type(new_data),
|
|
148
|
+
artifact_id=artifact.id,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
for dependent in self._dependents_of(artifact_id):
|
|
152
|
+
self._events.append(
|
|
153
|
+
Event(
|
|
154
|
+
type=EventType.ARTIFACT_STALE,
|
|
155
|
+
artifact_type=type(dependent.data),
|
|
156
|
+
artifact_id=dependent.id,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
self._stale.add(dependent.id)
|
|
160
|
+
return artifact
|
|
161
|
+
|
|
162
|
+
def delete(self, artifact_id: str) -> bool:
|
|
163
|
+
"""Deletes the artifact and generates ARTIFACT_DELETED."""
|
|
164
|
+
artifact = self._artifacts.pop(artifact_id, None)
|
|
165
|
+
if artifact is None:
|
|
166
|
+
return False
|
|
167
|
+
self._stale.discard(artifact_id)
|
|
168
|
+
self._events.append(
|
|
169
|
+
Event(
|
|
170
|
+
type=EventType.ARTIFACT_DELETED,
|
|
171
|
+
artifact_type=type(artifact.data),
|
|
172
|
+
artifact_id=artifact.id,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
@overload
|
|
178
|
+
def list_artifacts(self, artifact_type: None = None) -> list[Artifact[Any]]: ...
|
|
179
|
+
|
|
180
|
+
@overload
|
|
181
|
+
def list_artifacts(
|
|
182
|
+
self, artifact_type: type[TArtifact]
|
|
183
|
+
) -> list[Artifact[TArtifact]]: ...
|
|
184
|
+
|
|
185
|
+
def list_artifacts(
|
|
186
|
+
self, artifact_type: type[TArtifact] | None = None
|
|
187
|
+
) -> list[Artifact[Any]]:
|
|
188
|
+
"""Returns a list of artifacts, optionally filtered by data type."""
|
|
189
|
+
if artifact_type is None:
|
|
190
|
+
return list(self._artifacts.values())
|
|
191
|
+
return [
|
|
192
|
+
cast(Artifact[TArtifact], a)
|
|
193
|
+
for a in self._artifacts.values()
|
|
194
|
+
if isinstance(a.data, artifact_type)
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
def latest(self, artifact_type: type[TArtifact]) -> Artifact[TArtifact] | None:
|
|
198
|
+
"""The most recently created artifact of a type, or None.
|
|
199
|
+
|
|
200
|
+
Sugar over `list_artifacts` for "grab the latest answer/finding" —
|
|
201
|
+
the common read after a run.
|
|
202
|
+
"""
|
|
203
|
+
artifacts = self.list_artifacts(artifact_type)
|
|
204
|
+
if not artifacts:
|
|
205
|
+
return None
|
|
206
|
+
return max(artifacts, key=lambda a: a.created_at)
|
|
207
|
+
|
|
208
|
+
# ---- Context Views (§27): projection for the agent/prompt within the budget ----
|
|
209
|
+
|
|
210
|
+
def view(
|
|
211
|
+
self,
|
|
212
|
+
artifact_type: type[BaseModel] | tuple[type[BaseModel], ...] | None = None,
|
|
213
|
+
*,
|
|
214
|
+
condition: Callable[[Artifact[Any]], bool] | None = None,
|
|
215
|
+
limit: int | None = None,
|
|
216
|
+
) -> View:
|
|
217
|
+
"""Artifact projection for the agent (§27): by type/condition/limit.
|
|
218
|
+
|
|
219
|
+
The View does not copy state — it is references to artifacts plus
|
|
220
|
+
serialization for the prompt. `tokens_estimate` lets the agent stay within
|
|
221
|
+
the token budget (§58): build the view, check the estimate, reduce `limit`
|
|
222
|
+
if needed.
|
|
223
|
+
"""
|
|
224
|
+
if artifact_type is None:
|
|
225
|
+
artifacts = list(self._artifacts.values())
|
|
226
|
+
else:
|
|
227
|
+
artifacts = [
|
|
228
|
+
a for a in self._artifacts.values() if isinstance(a.data, artifact_type)
|
|
229
|
+
]
|
|
230
|
+
if condition is not None:
|
|
231
|
+
artifacts = [a for a in artifacts if condition(a)]
|
|
232
|
+
if limit is not None:
|
|
233
|
+
artifacts = artifacts[:limit]
|
|
234
|
+
return View(artifacts=artifacts)
|
|
235
|
+
|
|
236
|
+
# ---- Relations: the artifact graph (§15) ----
|
|
237
|
+
|
|
238
|
+
def link(self, source_id: str, relation: str, target_id: str) -> Relation:
|
|
239
|
+
"""Establishes a link `source_id —relation→ target_id` (idempotently, §42)."""
|
|
240
|
+
return self._relations.link(source_id, relation, target_id)
|
|
241
|
+
|
|
242
|
+
def unlink(
|
|
243
|
+
self,
|
|
244
|
+
source_id: str,
|
|
245
|
+
relation: str | None = None,
|
|
246
|
+
target_id: str | None = None,
|
|
247
|
+
) -> int:
|
|
248
|
+
"""Removes links; `relation`/`target_id` = None mean "any"."""
|
|
249
|
+
return self._relations.unlink(source_id, relation, target_id)
|
|
250
|
+
|
|
251
|
+
def relations(
|
|
252
|
+
self,
|
|
253
|
+
source_id: str | None = None,
|
|
254
|
+
relation: str | None = None,
|
|
255
|
+
target_id: str | None = None,
|
|
256
|
+
) -> list[Relation]:
|
|
257
|
+
"""All links, optionally filtered by any edge component."""
|
|
258
|
+
return self._relations.relations(source_id, relation, target_id)
|
|
259
|
+
|
|
260
|
+
def incoming(self, target_id: str, relation: str | None = None) -> list[Relation]:
|
|
261
|
+
"""Links pointing at `target_id` (for provenance: who references what)."""
|
|
262
|
+
return self.relations(target_id=target_id, relation=relation)
|
|
263
|
+
|
|
264
|
+
def related(
|
|
265
|
+
self, source_id: str, relation: str | None = None
|
|
266
|
+
) -> list[Artifact[Any]]:
|
|
267
|
+
"""Target artifacts of outgoing links (existing ones; "dangling" ones are skipped)."""
|
|
268
|
+
targets: list[Artifact[Any]] = []
|
|
269
|
+
seen: set[str] = set()
|
|
270
|
+
for rel in self.relations(source_id=source_id, relation=relation):
|
|
271
|
+
artifact = self._artifacts.get(rel.target_id)
|
|
272
|
+
if artifact is not None and rel.target_id not in seen:
|
|
273
|
+
targets.append(artifact)
|
|
274
|
+
seen.add(rel.target_id)
|
|
275
|
+
return targets
|
|
276
|
+
|
|
277
|
+
def dangling_relations(self) -> list[Relation]:
|
|
278
|
+
"""Links with a non-existent source or target (§69): the state is visible,
|
|
279
|
+
not hidden in a string."""
|
|
280
|
+
return [
|
|
281
|
+
rel
|
|
282
|
+
for rel in self._relations.values()
|
|
283
|
+
if rel.source_id not in self._artifacts
|
|
284
|
+
or rel.target_id not in self._artifacts
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
# ---- HITL: questions awaiting a human answer ----
|
|
288
|
+
|
|
289
|
+
def interrupt(
|
|
290
|
+
self,
|
|
291
|
+
question: str,
|
|
292
|
+
*,
|
|
293
|
+
kind: str = "general",
|
|
294
|
+
notes: dict[str, Any] | None = None,
|
|
295
|
+
) -> Artifact[PendingQuestion]:
|
|
296
|
+
"""Poses a question to a human: creates a PendingQuestion in the context."""
|
|
297
|
+
return self.create(
|
|
298
|
+
PendingQuestion(question=question, kind=kind, notes=notes or {})
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def pending_questions(self) -> list[Artifact[PendingQuestion]]:
|
|
302
|
+
"""Unanswered questions awaiting the human."""
|
|
303
|
+
return [a for a in self.list_artifacts(PendingQuestion) if not a.data.answered]
|
|
304
|
+
|
|
305
|
+
def has_pending_question(self) -> bool:
|
|
306
|
+
return bool(self.pending_questions())
|
|
307
|
+
|
|
308
|
+
def latest_pending_question(self) -> Artifact[PendingQuestion] | None:
|
|
309
|
+
questions = self.pending_questions()
|
|
310
|
+
if not questions:
|
|
311
|
+
return None
|
|
312
|
+
return max(questions, key=lambda a: a.created_at)
|
|
313
|
+
|
|
314
|
+
def resume(self, question_id: str, answer: str) -> Artifact[PendingQuestion] | None:
|
|
315
|
+
"""A human's answer is a regular patch: marks the question as answered.
|
|
316
|
+
|
|
317
|
+
Generates ARTIFACT_UPDATED, which agents subscribed to
|
|
318
|
+
PendingQuestion(answered=True) react to.
|
|
319
|
+
"""
|
|
320
|
+
artifact = self._artifacts.get(question_id)
|
|
321
|
+
if artifact is None or not isinstance(artifact.data, PendingQuestion):
|
|
322
|
+
return None
|
|
323
|
+
updated = artifact.data.model_copy(
|
|
324
|
+
update={
|
|
325
|
+
"answered": True,
|
|
326
|
+
"resolution": answer,
|
|
327
|
+
"resolved_at": datetime.now(UTC),
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
return self.update(question_id, updated)
|
|
331
|
+
|
|
332
|
+
def drain_events(self) -> list[Event]:
|
|
333
|
+
"""Drains and clears the event queue."""
|
|
334
|
+
events = self._events
|
|
335
|
+
self._events = []
|
|
336
|
+
return events
|
|
337
|
+
|
|
338
|
+
def clone(self) -> Context:
|
|
339
|
+
"""Deep copy of this context's live state. See `reactifact.branching`."""
|
|
340
|
+
from .branching import clone_context
|
|
341
|
+
|
|
342
|
+
return clone_context(self)
|
|
343
|
+
|
|
344
|
+
def merge_from(self, other: Context) -> None:
|
|
345
|
+
"""Two-way merge, no conflict detection. See `reactifact.branching`."""
|
|
346
|
+
from .branching import merge_context_from
|
|
347
|
+
|
|
348
|
+
merge_context_from(self, other)
|
|
349
|
+
|
|
350
|
+
def branch(self, *, name: str = "") -> Context:
|
|
351
|
+
"""Forks an isolated copy for alternative state exploration (§39).
|
|
352
|
+
|
|
353
|
+
The fork records a snapshot of its base, so a later `merge` of two
|
|
354
|
+
fork-mates can detect diverged artifacts three-way (§40). The branch
|
|
355
|
+
shares `resources` with the parent but is otherwise fully independent:
|
|
356
|
+
subsequent changes on either side do not affect the other. Algorithm
|
|
357
|
+
lives in `reactifact.branching.fork_context`.
|
|
358
|
+
"""
|
|
359
|
+
from .branching import fork_context
|
|
360
|
+
|
|
361
|
+
return fork_context(self, name=name)
|
|
362
|
+
|
|
363
|
+
def merge(self, other: Context, *, message: str = "Merged branch") -> None:
|
|
364
|
+
"""Merges `other` into `self` with explicit conflicts, atomically (§40).
|
|
365
|
+
|
|
366
|
+
Three-way merge against the shared fork base (the fork snapshot of
|
|
367
|
+
`other`, or of `self` when `other` has none) — raises `MergeConflict`
|
|
368
|
+
(`reactifact.branching.MergeConflict`, re-exported as `reactifact.MergeConflict`)
|
|
369
|
+
rather than silently choosing a side. Algorithm lives in
|
|
370
|
+
`reactifact.branching.merge_contexts`.
|
|
371
|
+
"""
|
|
372
|
+
from .branching import merge_contexts
|
|
373
|
+
|
|
374
|
+
merge_contexts(self, other, message=message)
|
|
375
|
+
|
|
376
|
+
def log_commit(self, commit: Commit) -> None:
|
|
377
|
+
"""Applies the commit to the repository: fills in parent/version, moves head."""
|
|
378
|
+
self._log.append(commit)
|
|
379
|
+
# A commit that (re-)writes an artifact refreshes it against its
|
|
380
|
+
# current reads — it can no longer be in the stale set.
|
|
381
|
+
for write in commit.writes:
|
|
382
|
+
self._stale.discard(write.artifact_id)
|
|
383
|
+
|
|
384
|
+
def commit_log(self) -> list[Commit]:
|
|
385
|
+
return self._log.history()
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def version(self) -> int:
|
|
389
|
+
"""Current Context version (number of applied commits)."""
|
|
390
|
+
return self._log.version
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def head_id(self) -> str | None:
|
|
394
|
+
"""Id of the last commit (HEAD)."""
|
|
395
|
+
return self._log.head_id
|
|
396
|
+
|
|
397
|
+
def history(self) -> list[Commit]:
|
|
398
|
+
"""History: an ordered chain of commits from the oldest to head."""
|
|
399
|
+
return self._log.history()
|
|
400
|
+
|
|
401
|
+
def diff(self, version_a: int, version_b: int) -> dict[str, Any]:
|
|
402
|
+
"""State delta between two Context versions.
|
|
403
|
+
|
|
404
|
+
Returns {"added": {id: data}, "removed": {id: data}, "changed": {id: {old, new}}}.
|
|
405
|
+
The diff compares the versioned state (commits); artifacts created directly
|
|
406
|
+
outside commits (the "working tree") do not participate.
|
|
407
|
+
"""
|
|
408
|
+
if not (0 <= version_a <= version_b <= self._log.version):
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"Invalid versions: {version_a}..{version_b} (head={self._log.version})"
|
|
411
|
+
)
|
|
412
|
+
snap_a = self._log.replay_state(version_a)
|
|
413
|
+
snap_b = self._log.replay_state(version_b)
|
|
414
|
+
result: dict[str, Any] = {"added": {}, "removed": {}, "changed": {}}
|
|
415
|
+
for aid in snap_b.keys() - snap_a.keys():
|
|
416
|
+
result["added"][aid] = snap_b[aid].model_dump()
|
|
417
|
+
for aid in snap_a.keys() - snap_b.keys():
|
|
418
|
+
result["removed"][aid] = snap_a[aid].model_dump()
|
|
419
|
+
for aid in snap_a.keys() & snap_b.keys():
|
|
420
|
+
if snap_a[aid] != snap_b[aid]:
|
|
421
|
+
result["changed"][aid] = {
|
|
422
|
+
"old": snap_a[aid].model_dump(),
|
|
423
|
+
"new": snap_b[aid].model_dump(),
|
|
424
|
+
}
|
|
425
|
+
return result
|
|
426
|
+
|
|
427
|
+
def snapshot(self) -> dict[str, Any]:
|
|
428
|
+
"""Consistent snapshot of the current artifact state (id → data)."""
|
|
429
|
+
return {aid: art.data.model_dump() for aid, art in self._artifacts.items()}
|
|
430
|
+
|
|
431
|
+
# ---- Invalidation / staleness (§43–44) based on recorded reads ----
|
|
432
|
+
|
|
433
|
+
def _producing_commit(self, artifact_id: str) -> Commit | None:
|
|
434
|
+
"""The last commit that wrote the artifact (create or update)."""
|
|
435
|
+
return self._log.producing_commit(artifact_id)
|
|
436
|
+
|
|
437
|
+
def _dependents_of(self, artifact_id: str) -> list[Artifact[Any]]:
|
|
438
|
+
"""Artifacts whose producing commit read `artifact_id` at an older version.
|
|
439
|
+
|
|
440
|
+
Looks up only `artifact_id`'s actual dependents via the commit log's
|
|
441
|
+
reverse index (`CommitLog.dependents_of`), not every artifact in the
|
|
442
|
+
context — used right after that artifact's version bumps to emit
|
|
443
|
+
`ARTIFACT_STALE` reactively instead of waiting for a `stale_artifacts()`
|
|
444
|
+
poll.
|
|
445
|
+
"""
|
|
446
|
+
current = self._artifacts.get(artifact_id)
|
|
447
|
+
if current is None:
|
|
448
|
+
return []
|
|
449
|
+
dependents: list[Artifact[Any]] = []
|
|
450
|
+
for aid in self._log.dependents_of(artifact_id):
|
|
451
|
+
artifact = self._artifacts.get(aid)
|
|
452
|
+
if artifact is None:
|
|
453
|
+
continue
|
|
454
|
+
commit = self._producing_commit(aid)
|
|
455
|
+
if commit is None:
|
|
456
|
+
continue
|
|
457
|
+
for read in commit.reads:
|
|
458
|
+
if read.artifact_id == artifact_id and read.version < current.version:
|
|
459
|
+
dependents.append(artifact)
|
|
460
|
+
break
|
|
461
|
+
return dependents
|
|
462
|
+
|
|
463
|
+
def stale_artifacts(self) -> list[Artifact[Any]]:
|
|
464
|
+
"""Artifacts whose parents (reads in the producing commit) are now newer versions.
|
|
465
|
+
|
|
466
|
+
Dependencies are built from the actual reads recorded by the runtime via
|
|
467
|
+
consumes — a link derived from execution, not an author-drawn graph.
|
|
468
|
+
Backed by the incrementally-maintained `_stale` set (kept current by
|
|
469
|
+
`update()`/`log_commit()`), not a rescan of every artifact.
|
|
470
|
+
"""
|
|
471
|
+
return [self._artifacts[aid] for aid in self._stale if aid in self._artifacts]
|
|
472
|
+
|
|
473
|
+
def has_stale(self) -> bool:
|
|
474
|
+
return bool(self._stale)
|
|
475
|
+
|
|
476
|
+
def _recompute_stale(self) -> None:
|
|
477
|
+
"""Full rebuild of `_stale` from current artifacts + the commit log.
|
|
478
|
+
|
|
479
|
+
Only needed after a bulk rewrite that bypasses the normal
|
|
480
|
+
`create`/`update`/`log_commit` path (`checkout`, `clone`, `from_dict`)
|
|
481
|
+
— those are inherently O(state) operations already, unlike the hot
|
|
482
|
+
`update()`/`stale_artifacts()` path this index exists to keep cheap.
|
|
483
|
+
"""
|
|
484
|
+
stale: set[str] = set()
|
|
485
|
+
for artifact_id in self._artifacts:
|
|
486
|
+
commit = self._producing_commit(artifact_id)
|
|
487
|
+
if commit is None:
|
|
488
|
+
continue
|
|
489
|
+
for read in commit.reads:
|
|
490
|
+
current = self._artifacts.get(read.artifact_id)
|
|
491
|
+
if current is not None and current.version > read.version:
|
|
492
|
+
stale.add(artifact_id)
|
|
493
|
+
break
|
|
494
|
+
self._stale = stale
|
|
495
|
+
|
|
496
|
+
def _rebuild_artifacts_from_commits(
|
|
497
|
+
self, upto_version: int
|
|
498
|
+
) -> dict[str, Artifact[Any]]:
|
|
499
|
+
state = self._log.replay_state(upto_version)
|
|
500
|
+
return {aid: Artifact(data=data, id=aid) for aid, data in state.items()}
|
|
501
|
+
|
|
502
|
+
def checkout(self, version: int) -> None:
|
|
503
|
+
"""Moves head back to a previous version (rollback along the commit chain).
|
|
504
|
+
|
|
505
|
+
Artifacts not part of the versioned history (created directly outside
|
|
506
|
+
commits — the "working tree") are preserved.
|
|
507
|
+
"""
|
|
508
|
+
if not (0 <= version <= self._log.version):
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"Invalid checkout version: {version} (head={self._log.version})"
|
|
511
|
+
)
|
|
512
|
+
touched: set[str] = set()
|
|
513
|
+
for commit in self._log.commits_from(version):
|
|
514
|
+
for op in commit.operations:
|
|
515
|
+
if isinstance(op, (Create, Update, Delete)) and op.artifact_id:
|
|
516
|
+
touched.add(op.artifact_id)
|
|
517
|
+
rebuilt = self._rebuild_artifacts_from_commits(version)
|
|
518
|
+
for aid, art in self._artifacts.items():
|
|
519
|
+
if aid not in touched:
|
|
520
|
+
rebuilt[aid] = art
|
|
521
|
+
self._artifacts = rebuilt
|
|
522
|
+
|
|
523
|
+
# Relations: those committed up to `version` plus the "working tree"
|
|
524
|
+
# (created directly outside commits) are kept, as with artifacts. Links
|
|
525
|
+
# introduced by commits in the [version:] range are rolled back.
|
|
526
|
+
committed_now = self._log.replay_relations(self._log.version)
|
|
527
|
+
working_tree_rels = {
|
|
528
|
+
key: rel for key, rel in self._relations.items() if key not in committed_now
|
|
529
|
+
}
|
|
530
|
+
self._relations = RelationGraph.from_mapping(
|
|
531
|
+
{**self._log.replay_relations(version), **working_tree_rels}
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
self._events = []
|
|
535
|
+
self._log.truncate(version)
|
|
536
|
+
self._recompute_stale()
|
|
537
|
+
|
|
538
|
+
def to_dict(self) -> dict[str, Any]:
|
|
539
|
+
return {
|
|
540
|
+
"version": self._log.version,
|
|
541
|
+
"head_id": self._log.head_id,
|
|
542
|
+
"artifacts": {aid: art.to_dict() for aid, art in self._artifacts.items()},
|
|
543
|
+
"relations": self._relations.to_dict(),
|
|
544
|
+
"commits": self._log.to_dict(),
|
|
545
|
+
"fork_name": self._fork_name,
|
|
546
|
+
"base": self._base.to_dict() if self._base is not None else None,
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
@classmethod
|
|
550
|
+
def from_dict(cls, d: dict[str, Any]) -> Context:
|
|
551
|
+
ws = cls()
|
|
552
|
+
for aid, art_dict in d["artifacts"].items():
|
|
553
|
+
artifact = Artifact.from_dict(art_dict)
|
|
554
|
+
ws._artifacts[aid] = artifact
|
|
555
|
+
ws._relations = RelationGraph.from_dict(d.get("relations", []))
|
|
556
|
+
ws._log = CommitLog.from_dict(
|
|
557
|
+
d["commits"], version=d.get("version"), head_id=d.get("head_id")
|
|
558
|
+
)
|
|
559
|
+
ws._fork_name = d.get("fork_name", "")
|
|
560
|
+
ws._base = Context.from_dict(d["base"]) if d.get("base") is not None else None
|
|
561
|
+
ws._recompute_stale()
|
|
562
|
+
return ws
|
|
563
|
+
|
|
564
|
+
async def save_checkpoint(self, backend_or_path: str | CheckpointBackend) -> None:
|
|
565
|
+
backend: CheckpointBackend
|
|
566
|
+
if isinstance(backend_or_path, str):
|
|
567
|
+
backend = FileBackend(backend_or_path)
|
|
568
|
+
else:
|
|
569
|
+
backend = backend_or_path
|
|
570
|
+
await backend.save(self.to_dict())
|
|
571
|
+
|
|
572
|
+
@classmethod
|
|
573
|
+
async def load_checkpoint(cls, backend_or_path: str | CheckpointBackend) -> Context:
|
|
574
|
+
backend: CheckpointBackend
|
|
575
|
+
if isinstance(backend_or_path, str):
|
|
576
|
+
backend = FileBackend(backend_or_path)
|
|
577
|
+
else:
|
|
578
|
+
backend = backend_or_path
|
|
579
|
+
data = await backend.load()
|
|
580
|
+
return cls.from_dict(data)
|
|
581
|
+
|
|
582
|
+
async def to_kv(self, backend: KVBackend, key: str) -> None:
|
|
583
|
+
"""Serializes and stores this context under `key` in a KV backend.
|
|
584
|
+
|
|
585
|
+
The one `to_dict()` round-trip shared by `SessionStore`/`BranchStore`
|
|
586
|
+
(session_id / branch keys are just a naming convention over the same
|
|
587
|
+
backend, §39) — call this instead of hand-rolling `backend.set(key,
|
|
588
|
+
context.to_dict())`.
|
|
589
|
+
"""
|
|
590
|
+
await backend.set(key, self.to_dict())
|
|
591
|
+
|
|
592
|
+
@classmethod
|
|
593
|
+
async def from_kv(cls, backend: KVBackend, key: str) -> Context | None:
|
|
594
|
+
"""Loads a context previously stored with `to_kv`, or None if absent."""
|
|
595
|
+
data = await backend.get(key)
|
|
596
|
+
return cls.from_dict(data) if data is not None else None
|
|
597
|
+
|
|
598
|
+
def __repr__(self) -> str:
|
|
599
|
+
return f"<Context artifacts={len(self._artifacts)} pending_events={len(self._events)}>"
|