agent-bios 0.14.0 → 0.16.0
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.
- package/DEPENDENCIES.md +35 -12
- package/README.md +346 -31
- package/claude/CLAUDE.md +2 -2
- package/claude/agents/frontier.md +1 -1
- package/claude/agents/sweep.md +3 -3
- package/claude/agents/workhorse.md +2 -2
- package/claude/guides/claude-prompting.md +119 -34
- package/claude/guides/cli-multi-model-workflow.md +33 -15
- package/claude/guides/gpt-prompting.md +148 -28
- package/claude/guides/review-request.md +27 -0
- package/claude/guides/session-distill-workflow.md +54 -2
- package/claude/guides/slide-writing/RUNBOOK.md +137 -0
- package/claude/guides/slide-writing/scripts/pair.py +979 -0
- package/claude/guides/slide-writing/scripts/render.mjs +82 -0
- package/claude/guides/slide-writing.md +195 -0
- package/claude/guides/svg-visualization-guide.md +9 -0
- package/claude/guides/verification-discipline.md +5 -1
- package/claude/hooks/tooling-gotchas-hook.py +7 -5
- package/codex/AGENTS.md +2 -2
- package/codex/agents/frontier.toml +2 -1
- package/codex/agents/reviewer.toml +1 -1
- package/codex/agents/sweep.toml +3 -3
- package/codex/agents/workhorse.toml +1 -1
- package/codex/config-additions.toml +1 -1
- package/codex/guides/claude-prompting.md +119 -34
- package/codex/guides/cli-multi-model-workflow.md +33 -15
- package/codex/guides/gpt-prompting.md +148 -28
- package/codex/guides/review-request.md +27 -0
- package/codex/guides/session-distill-workflow.md +54 -2
- package/codex/guides/slide-writing/RUNBOOK.md +137 -0
- package/codex/guides/slide-writing/scripts/pair.py +979 -0
- package/codex/guides/slide-writing/scripts/render.mjs +82 -0
- package/codex/guides/slide-writing.md +195 -0
- package/codex/guides/svg-visualization-guide.md +9 -0
- package/codex/guides/verification-discipline.md +5 -1
- package/compose/assemble.py +290 -14
- package/compose/bootstrap/SKILL.md +119 -0
- package/compose/check-domains.py +102 -9
- package/compose/corpus-state.py +1174 -0
- package/compose/corpus.py +387 -0
- package/compose/corpus_catalog.py +882 -0
- package/compose/corpus_install.py +1617 -0
- package/compose/corpus_session.py +726 -0
- package/compose/corpus_store.py +1414 -0
- package/compose/corpus_transaction.py +236 -0
- package/compose/corpus_ui.py +644 -0
- package/compose/domains.json +101 -100
- package/compose/write-update-cache.py +53 -0
- package/install.sh +174 -24
- package/launch/agent-launch.py +1327 -184
- package/launch/agent-launch.toml +12 -16
- package/launch/i18n/en.toml +113 -7
- package/launch/i18n/ja.toml +113 -7
- package/launch/i18n/ko.toml +113 -7
- package/learn/collect-learning.py +46 -19
- package/learn/migrate-learnings.py +10 -1
- package/package.json +13 -3
- package/provenance.json +1 -1
- package/session-cost.py +22 -2
- package/wrappers/codex-helm.sh +3 -3
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Private, revision-checked storage for an activated corpus.
|
|
3
|
+
|
|
4
|
+
The store deliberately owns source state and immutable projections only. It
|
|
5
|
+
does not alter a host configuration or claim that a snapshot was loaded by a
|
|
6
|
+
host; that is the launch adapter's job.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import contextlib
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
import re
|
|
19
|
+
import tempfile
|
|
20
|
+
import uuid
|
|
21
|
+
from typing import Any, Iterator
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from corpus_transaction import transaction_lock, guard_pending, pending_operations, operation_scope_active
|
|
25
|
+
except ImportError:
|
|
26
|
+
from .corpus_transaction import transaction_lock, guard_pending, pending_operations, operation_scope_active
|
|
27
|
+
|
|
28
|
+
SCHEMA_VERSION = 1
|
|
29
|
+
LOCAL_PACKAGE = "@local/personal"
|
|
30
|
+
SURFACES = {"always", "relevant", "requested", "event", "delegated"}
|
|
31
|
+
KINDS = {"rule", "guide", "skill", "hook", "agent"}
|
|
32
|
+
ITEM_FIELDS = {
|
|
33
|
+
"ref", "package_id", "item_id", "title", "body", "surface", "tier",
|
|
34
|
+
"domains", "kind", "members", "origin", "routes", "dependencies",
|
|
35
|
+
"active", "learning_source", "hook", "primary_member",
|
|
36
|
+
}
|
|
37
|
+
RUNTIME_FIELDS = {
|
|
38
|
+
"digest", "revision", "content_ref", "path", "state", "created_at",
|
|
39
|
+
"updated_at", "baseline_ref", "plan_id", "history_id",
|
|
40
|
+
}
|
|
41
|
+
LEARNING_FIELDS = {"schema_version", "learning_id", "lesson", "domain", "created", "supporting_sessions",
|
|
42
|
+
"criteria", "classification", "proposed_domain", "context"}
|
|
43
|
+
LEARNING_ID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CorpusStoreError(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StaleRevision(CorpusStoreError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ValidationError(CorpusStoreError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _canonical(value: Any) -> bytes:
|
|
59
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _digest(value: Any) -> str:
|
|
63
|
+
if isinstance(value, bytes):
|
|
64
|
+
return hashlib.sha256(value).hexdigest()
|
|
65
|
+
return hashlib.sha256(_canonical(value)).hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _utcnow() -> str:
|
|
69
|
+
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="microseconds")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _safe_part(value: str, label: str = "path") -> str:
|
|
73
|
+
if not isinstance(value, str) or not value or "\x00" in value:
|
|
74
|
+
raise ValidationError(f"invalid {label}")
|
|
75
|
+
path = PurePosixPath(value)
|
|
76
|
+
if path.is_absolute() or ".." in path.parts or path == PurePosixPath("."):
|
|
77
|
+
raise ValidationError(f"unowned {label}: {value!r}")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _host(value: str) -> str:
|
|
82
|
+
if value not in {"claude", "codex"}:
|
|
83
|
+
raise ValidationError(f"unsupported host: {value!r}")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reject_symlink_path(path: Path, *, leaf: bool = True) -> None:
|
|
88
|
+
"""Refuse a symlink at the store-owned path boundary.
|
|
89
|
+
|
|
90
|
+
Platform temporary directories commonly sit below system symlink aliases
|
|
91
|
+
(macOS `/var` is one), so the boundary deliberately stops at the configured
|
|
92
|
+
store path or its direct parent. Every store-created nested root is checked
|
|
93
|
+
when it becomes the direct parent of a write; the lock itself also uses
|
|
94
|
+
``O_NOFOLLOW`` against a final-component substitution race.
|
|
95
|
+
"""
|
|
96
|
+
target = path if leaf else path.parent
|
|
97
|
+
if target.exists() or target.is_symlink():
|
|
98
|
+
if target.is_symlink():
|
|
99
|
+
raise CorpusStoreError(f"symlink is not a corpus-store path: {target}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _json_read(path: Path, default: Any = None) -> Any:
|
|
103
|
+
_reject_symlink_path(path)
|
|
104
|
+
if not path.is_file():
|
|
105
|
+
return copy.deepcopy(default)
|
|
106
|
+
try:
|
|
107
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
except (OSError, ValueError) as exc:
|
|
109
|
+
raise CorpusStoreError(f"unreadable state at {path}: {exc}") from exc
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _atomic_write(path: Path, value: Any) -> None:
|
|
113
|
+
_reject_symlink_path(path)
|
|
114
|
+
_reject_symlink_path(path, leaf=False)
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
body = _canonical(value) + b"\n"
|
|
117
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
118
|
+
tmp = Path(tmp_name)
|
|
119
|
+
try:
|
|
120
|
+
with os.fdopen(fd, "wb") as handle:
|
|
121
|
+
handle.write(body)
|
|
122
|
+
handle.flush()
|
|
123
|
+
os.fsync(handle.fileno())
|
|
124
|
+
if path.exists():
|
|
125
|
+
tmp.chmod(path.stat().st_mode & 0o777)
|
|
126
|
+
os.replace(tmp, path)
|
|
127
|
+
# Persist the directory entry as well as the file bytes.
|
|
128
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
129
|
+
try:
|
|
130
|
+
os.fsync(directory)
|
|
131
|
+
finally:
|
|
132
|
+
os.close(directory)
|
|
133
|
+
finally:
|
|
134
|
+
with contextlib.suppress(FileNotFoundError):
|
|
135
|
+
tmp.unlink()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _copy_json(value: Any) -> Any:
|
|
139
|
+
return json.loads(_canonical(value))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _rewrite_staged_paths(staging: Path, destination: Path) -> None:
|
|
143
|
+
"""Replace compiler-internal absolute staging links before publication."""
|
|
144
|
+
old, new = str(staging), str(destination)
|
|
145
|
+
for path in staging.rglob("*"):
|
|
146
|
+
if not path.is_file():
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
text = path.read_text(encoding="utf-8")
|
|
150
|
+
except UnicodeDecodeError as exc:
|
|
151
|
+
raise CorpusStoreError(f"snapshot compiler emitted non-text member: {path}") from exc
|
|
152
|
+
if old in text:
|
|
153
|
+
path.write_text(text.replace(old, new), encoding="utf-8")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _snapshot_relative_paths(files: Any) -> list[str]:
|
|
157
|
+
if not isinstance(files, list) or not files or not all(isinstance(path, str) for path in files):
|
|
158
|
+
raise ValidationError("snapshot compiler returned an empty or invalid file set")
|
|
159
|
+
normalized = [_safe_part(path, "snapshot file") for path in files]
|
|
160
|
+
if len(set(normalized)) != len(normalized):
|
|
161
|
+
raise ValidationError("snapshot compiler returned duplicate files")
|
|
162
|
+
return sorted(normalized)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _snapshot_file_digests(root: Path, files: list[str]) -> dict[str, str]:
|
|
166
|
+
expected = sorted(set(files) | {"output.json"})
|
|
167
|
+
result: dict[str, str] = {}
|
|
168
|
+
for relative in expected:
|
|
169
|
+
path = root / _safe_part(relative, "snapshot file")
|
|
170
|
+
if path.is_symlink() or not path.is_file():
|
|
171
|
+
raise CorpusStoreError(f"snapshot output is missing or symlinked: {relative}")
|
|
172
|
+
result[relative] = _digest(path.read_bytes())
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _snapshot_assets(value: Any, files: list[str]) -> dict[str, Any]:
|
|
177
|
+
if not isinstance(value, dict) or set(value) - {"claude_plugins"}:
|
|
178
|
+
raise ValidationError("invalid snapshot native assets")
|
|
179
|
+
plugins = value.get("claude_plugins", [])
|
|
180
|
+
if not isinstance(plugins, list) or not all(isinstance(path, str) for path in plugins):
|
|
181
|
+
raise ValidationError("snapshot plugins must be relative paths")
|
|
182
|
+
if len(plugins) != len(set(plugins)):
|
|
183
|
+
raise ValidationError("duplicate snapshot plugin")
|
|
184
|
+
for relative in plugins:
|
|
185
|
+
_safe_part(relative, "snapshot plugin")
|
|
186
|
+
if PurePosixPath(relative).as_posix() != relative:
|
|
187
|
+
raise ValidationError("snapshot plugin path is not canonical")
|
|
188
|
+
if f"{relative}/.claude-plugin/plugin.json" not in files:
|
|
189
|
+
raise ValidationError("snapshot plugin has no inventoried manifest")
|
|
190
|
+
return _copy_json(value)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def verify_snapshot(path: Path, expected_content_ref: str) -> dict[str, Any]:
|
|
194
|
+
"""Validate a persisted snapshot without consulting current authoring state."""
|
|
195
|
+
root = Path(path)
|
|
196
|
+
_safe_part(expected_content_ref, "content ref")
|
|
197
|
+
if root.is_symlink() or not root.is_dir():
|
|
198
|
+
raise ValidationError(f"snapshot is missing or symlinked: {root}")
|
|
199
|
+
inventory = _json_read(root / "inventory.json")
|
|
200
|
+
output = _json_read(root / "output.json")
|
|
201
|
+
if not isinstance(inventory, dict) or not isinstance(output, dict):
|
|
202
|
+
raise ValidationError("snapshot metadata is unreadable")
|
|
203
|
+
inputs = inventory.get("inputs")
|
|
204
|
+
if not isinstance(inputs, dict) or _digest(inputs) != expected_content_ref:
|
|
205
|
+
raise ValidationError("snapshot content ref does not match canonical inputs")
|
|
206
|
+
files = _snapshot_relative_paths(output.get("files"))
|
|
207
|
+
_snapshot_assets(output.get("assets", {}), files)
|
|
208
|
+
digests = inventory.get("file_digests")
|
|
209
|
+
if not isinstance(digests, dict) or not digests:
|
|
210
|
+
raise ValidationError("snapshot has no file digest inventory")
|
|
211
|
+
expected = set(files) | {"output.json"}
|
|
212
|
+
if set(digests) != expected:
|
|
213
|
+
raise ValidationError("snapshot digest file set is not exact")
|
|
214
|
+
actual_files: set[str] = set()
|
|
215
|
+
for candidate in root.rglob("*"):
|
|
216
|
+
if candidate.is_symlink():
|
|
217
|
+
raise ValidationError(f"snapshot contains symlink: {candidate.relative_to(root)}")
|
|
218
|
+
if candidate.is_file() and candidate != root / "inventory.json":
|
|
219
|
+
actual_files.add(candidate.relative_to(root).as_posix())
|
|
220
|
+
if actual_files != expected:
|
|
221
|
+
raise ValidationError("snapshot persisted file set is not exact")
|
|
222
|
+
for relative, digest in digests.items():
|
|
223
|
+
if not isinstance(digest, str) or _digest((root / relative).read_bytes()) != digest:
|
|
224
|
+
raise ValidationError(f"snapshot file digest mismatch: {relative}")
|
|
225
|
+
return {"content_ref": expected_content_ref, "path": str(root), "inputs": inputs,
|
|
226
|
+
"items": inventory.get("items"), "file_digests": digests, "output": output}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class CorpusStore:
|
|
230
|
+
"""The private corpus source, transaction journal, and snapshot compiler.
|
|
231
|
+
|
|
232
|
+
``state_root`` is deploy/runtime-owned. ``user_root`` is deliberately a
|
|
233
|
+
separate authority: install never overwrites it.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, repo: Path, state_root: Path | None = None, user_root: Path | None = None):
|
|
237
|
+
self.repo = Path(repo).resolve()
|
|
238
|
+
self.state_root = Path(state_root or os.environ.get(
|
|
239
|
+
"AGENT_BIOS_STATE_DIR", str(Path.home() / ".local/share/agent-bios")
|
|
240
|
+
)).expanduser()
|
|
241
|
+
self.user_root = Path(user_root or os.environ.get(
|
|
242
|
+
"AGENT_BIOS_CORPUS_DIR", str(Path.home() / ".config/agent-bios/corpus")
|
|
243
|
+
)).expanduser()
|
|
244
|
+
self.runtime = self.state_root / "runtime"
|
|
245
|
+
self.sessions = self.state_root / "sessions"
|
|
246
|
+
|
|
247
|
+
# ---- layout and locking -------------------------------------------------
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def _runtime_state_path(self) -> Path:
|
|
251
|
+
return self.runtime / "state.json"
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def _user_state_path(self) -> Path:
|
|
255
|
+
return self.user_root / "state.json"
|
|
256
|
+
|
|
257
|
+
@contextlib.contextmanager
|
|
258
|
+
def _lock(self) -> Iterator[None]:
|
|
259
|
+
_reject_symlink_path(self.state_root, leaf=False)
|
|
260
|
+
_reject_symlink_path(self.state_root)
|
|
261
|
+
_reject_symlink_path(self.user_root, leaf=False)
|
|
262
|
+
_reject_symlink_path(self.user_root)
|
|
263
|
+
self.state_root.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
with transaction_lock(self.state_root):
|
|
265
|
+
yield
|
|
266
|
+
|
|
267
|
+
def _empty_runtime(self) -> dict[str, Any]:
|
|
268
|
+
return {
|
|
269
|
+
"schema_version": SCHEMA_VERSION,
|
|
270
|
+
"last_successful_install_ref": None,
|
|
271
|
+
"selected_baseline_ref": None,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
def _empty_user(self) -> dict[str, Any]:
|
|
275
|
+
return {
|
|
276
|
+
"schema_version": SCHEMA_VERSION,
|
|
277
|
+
"items": {},
|
|
278
|
+
"overrides": {},
|
|
279
|
+
"tombstones": {},
|
|
280
|
+
"selection": None,
|
|
281
|
+
"learning_suppressions": {"claude": [], "codex": []},
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
def _runtime_state(self) -> dict[str, Any]:
|
|
285
|
+
state = _json_read(self._runtime_state_path, self._empty_runtime())
|
|
286
|
+
if state.get("schema_version") != SCHEMA_VERSION:
|
|
287
|
+
raise CorpusStoreError("runtime state schema mismatch")
|
|
288
|
+
return state
|
|
289
|
+
|
|
290
|
+
def _user_state(self) -> dict[str, Any]:
|
|
291
|
+
state = _json_read(self._user_state_path, self._empty_user())
|
|
292
|
+
if state.get("schema_version") != SCHEMA_VERSION:
|
|
293
|
+
raise CorpusStoreError("personal state schema mismatch")
|
|
294
|
+
# v1 sources created before reset acquired this projection field. It
|
|
295
|
+
# has a deterministic empty meaning and is persisted on the next
|
|
296
|
+
# authoring transaction rather than rewritten by a read.
|
|
297
|
+
state.setdefault("learning_suppressions", {"claude": [], "codex": []})
|
|
298
|
+
for field, typ in (("items", dict), ("overrides", dict), ("tombstones", dict), ("learning_suppressions", dict)):
|
|
299
|
+
if not isinstance(state.get(field), typ):
|
|
300
|
+
raise CorpusStoreError(f"personal state has invalid {field}")
|
|
301
|
+
return state
|
|
302
|
+
|
|
303
|
+
def _write_transaction(self, tx_id: str, record: dict[str, Any]) -> None:
|
|
304
|
+
_atomic_write(self.runtime / "transactions" / tx_id / "journal.json", record)
|
|
305
|
+
|
|
306
|
+
def _recover_locked(self) -> None:
|
|
307
|
+
"""Finish a source publication interrupted after its PREPARED journal.
|
|
308
|
+
|
|
309
|
+
User and runtime sources have separate ownership roots and cannot share
|
|
310
|
+
one rename. The journal therefore records both candidate documents
|
|
311
|
+
before either pointer moves. Recovery accepts only an exact prior/next
|
|
312
|
+
pair and finishes the known transaction; any third value is evidence of
|
|
313
|
+
an out-of-band writer and remains a fail-loud recovery record.
|
|
314
|
+
"""
|
|
315
|
+
guard_pending(self.state_root)
|
|
316
|
+
root = self.runtime / "transactions"
|
|
317
|
+
if not root.is_dir():
|
|
318
|
+
return
|
|
319
|
+
for journal_path in sorted(root.glob("*/journal.json")):
|
|
320
|
+
journal = _json_read(journal_path)
|
|
321
|
+
if isinstance(journal, dict) and journal.get("state") == "NEEDS_RECOVERY":
|
|
322
|
+
raise CorpusStoreError(f"transaction {journal_path.parent.name} needs manual recovery")
|
|
323
|
+
if not isinstance(journal, dict) or journal.get("state") != "PREPARED":
|
|
324
|
+
continue
|
|
325
|
+
plan = journal.get("plan")
|
|
326
|
+
if not isinstance(plan, dict) or not isinstance(plan.get("before"), dict) or not isinstance(plan.get("after"), dict):
|
|
327
|
+
raise CorpusStoreError(f"invalid prepared transaction {journal_path.parent.name}")
|
|
328
|
+
current_runtime = _json_read(self._runtime_state_path, self._empty_runtime())
|
|
329
|
+
current_user = _json_read(self._user_state_path, self._empty_user())
|
|
330
|
+
prior, candidate = plan["before"], plan["after"]
|
|
331
|
+
known_runtime = current_runtime in (prior.get("runtime"), candidate.get("runtime"))
|
|
332
|
+
known_user = current_user in (prior.get("user"), candidate.get("user"))
|
|
333
|
+
if not known_runtime or not known_user:
|
|
334
|
+
journal["state"] = "NEEDS_RECOVERY"
|
|
335
|
+
journal["recovery_error"] = "source differs from prepared transaction prior and candidate"
|
|
336
|
+
_atomic_write(journal_path, journal)
|
|
337
|
+
raise CorpusStoreError(f"transaction {journal_path.parent.name} needs manual recovery")
|
|
338
|
+
history_id = journal.get("history_id")
|
|
339
|
+
if history_id is None:
|
|
340
|
+
instant = plan.get("created_at", journal.get("prepared_at", _utcnow()))
|
|
341
|
+
history_id = f"{instant.replace(':', '').replace('+00:00', 'Z')}-{journal_path.parent.name[:12]}"
|
|
342
|
+
journal["history_id"] = history_id
|
|
343
|
+
_atomic_write(journal_path, journal)
|
|
344
|
+
history = {"runtime": prior["runtime"], "user": prior["user"],
|
|
345
|
+
"revision": plan.get("expected_revision")}
|
|
346
|
+
history_path = self.user_root / "history" / history_id / "state.json"
|
|
347
|
+
if not history_path.exists():
|
|
348
|
+
_atomic_write(history_path, history)
|
|
349
|
+
if plan.get("details", {}).get("operation") == "reset":
|
|
350
|
+
trash_path = self.user_root / "trash" / history_id / "state.json"
|
|
351
|
+
if not trash_path.exists():
|
|
352
|
+
_atomic_write(trash_path, history)
|
|
353
|
+
_atomic_write(self._user_state_path, candidate["user"])
|
|
354
|
+
_atomic_write(self._runtime_state_path, candidate["runtime"])
|
|
355
|
+
journal["state"] = "RECOVERED_COMMITTED"
|
|
356
|
+
journal["recovered_at"] = _utcnow()
|
|
357
|
+
_atomic_write(journal_path, journal)
|
|
358
|
+
|
|
359
|
+
# ---- baseline and source resolution ------------------------------------
|
|
360
|
+
|
|
361
|
+
def _catalog_module(self):
|
|
362
|
+
try:
|
|
363
|
+
return importlib.import_module("corpus_catalog")
|
|
364
|
+
except ModuleNotFoundError:
|
|
365
|
+
# Package execution is useful in tests and is harmless in a checkout.
|
|
366
|
+
import sys
|
|
367
|
+
sys.path.insert(0, str(self.repo / "compose"))
|
|
368
|
+
try:
|
|
369
|
+
return importlib.import_module("corpus_catalog")
|
|
370
|
+
except ModuleNotFoundError as exc:
|
|
371
|
+
raise CorpusStoreError("compose/corpus_catalog.py is required") from exc
|
|
372
|
+
|
|
373
|
+
def _baseline_dir(self, ref: str) -> Path:
|
|
374
|
+
_safe_part(ref, "baseline reference")
|
|
375
|
+
return self.runtime / "baselines" / ref
|
|
376
|
+
|
|
377
|
+
def _normalize_content(self, item: dict[str, Any], *, allow_legacy: bool = False) -> dict[str, Any]:
|
|
378
|
+
return self._catalog_module().normalize_content(item, allow_legacy=allow_legacy)
|
|
379
|
+
|
|
380
|
+
def _read_baseline(self, ref: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
381
|
+
root = self._baseline_dir(ref)
|
|
382
|
+
inventory = _json_read(root / "inventory.json")
|
|
383
|
+
defaults = _json_read(root / "defaults.json")
|
|
384
|
+
if not isinstance(inventory, dict) or not isinstance(defaults, dict):
|
|
385
|
+
raise CorpusStoreError(f"missing baseline tuple {ref}")
|
|
386
|
+
return inventory, defaults
|
|
387
|
+
|
|
388
|
+
def _baseline_promotions(self, ref: str) -> dict[str, Any]:
|
|
389
|
+
data = _json_read(self._baseline_dir(ref) / "promotions.json", {"version": 0, "promotions": []})
|
|
390
|
+
if not isinstance(data, dict) or not isinstance(data.get("promotions"), list):
|
|
391
|
+
raise CorpusStoreError(f"invalid promotion data in baseline {ref}")
|
|
392
|
+
return data
|
|
393
|
+
|
|
394
|
+
def _selected_baseline(self, runtime: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
|
395
|
+
ref = runtime.get("selected_baseline_ref")
|
|
396
|
+
if not isinstance(ref, str):
|
|
397
|
+
raise CorpusStoreError("no installed baseline; run install first")
|
|
398
|
+
inventory, defaults = self._read_baseline(ref)
|
|
399
|
+
return ref, inventory, defaults
|
|
400
|
+
|
|
401
|
+
def _learning_events(self, host: str) -> list[dict[str, Any]]:
|
|
402
|
+
_host(host)
|
|
403
|
+
_reject_symlink_path(self.user_root)
|
|
404
|
+
_reject_symlink_path(self.user_root / "learnings")
|
|
405
|
+
_reject_symlink_path(self.user_root / "learnings" / host)
|
|
406
|
+
path = self.user_root / "learnings" / host / "events.jsonl"
|
|
407
|
+
_reject_symlink_path(path)
|
|
408
|
+
if not path.is_file():
|
|
409
|
+
return []
|
|
410
|
+
events: list[dict[str, Any]] = []
|
|
411
|
+
for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
412
|
+
if not raw.strip():
|
|
413
|
+
continue
|
|
414
|
+
try:
|
|
415
|
+
event = json.loads(raw)
|
|
416
|
+
except ValueError as exc:
|
|
417
|
+
raise CorpusStoreError(f"invalid learning event {path}:{line_no}") from exc
|
|
418
|
+
if not isinstance(event, dict):
|
|
419
|
+
raise CorpusStoreError(f"invalid learning event {path}:{line_no}")
|
|
420
|
+
events.append(event)
|
|
421
|
+
return events
|
|
422
|
+
|
|
423
|
+
def _learning_item(self, host: str, event: dict[str, Any]) -> dict[str, Any] | None:
|
|
424
|
+
learning_id = event["learning_id"]
|
|
425
|
+
body = event["lesson"]
|
|
426
|
+
return {
|
|
427
|
+
"ref": f"@local/learnings-{host}:{learning_id}",
|
|
428
|
+
"package_id": f"@local/learnings-{host}", "item_id": learning_id,
|
|
429
|
+
"title": event["domain"],
|
|
430
|
+
"body": body, "surface": event.get("surface", "always"),
|
|
431
|
+
"tier": event.get("tier", "env-personal"), "domains": ["personal"], "kind": "rule",
|
|
432
|
+
"members": {"learning.md": body}, "origin": {"type": "learning", "host": host},
|
|
433
|
+
"learning_source": True,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
@staticmethod
|
|
437
|
+
def _learning_host_from_ref(ref: str) -> str | None:
|
|
438
|
+
prefix = "@local/learnings-"
|
|
439
|
+
if not ref.startswith(prefix) or ":" not in ref:
|
|
440
|
+
return None
|
|
441
|
+
host = ref[len(prefix):].split(":", 1)[0]
|
|
442
|
+
return host if host in {"claude", "codex"} else None
|
|
443
|
+
|
|
444
|
+
def _effective_items(
|
|
445
|
+
self, runtime: dict[str, Any], user: dict[str, Any], host: str | None = None, include_suppressed: bool = False
|
|
446
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any], str]:
|
|
447
|
+
baseline_ref, inventory, defaults = self._selected_baseline(runtime)
|
|
448
|
+
source = inventory.get("items")
|
|
449
|
+
if not isinstance(source, list):
|
|
450
|
+
raise CorpusStoreError(f"baseline {baseline_ref} has no item inventory")
|
|
451
|
+
items: dict[str, dict[str, Any]] = {}
|
|
452
|
+
for raw in source:
|
|
453
|
+
item = self._validate_item(raw, allow_origin=True)
|
|
454
|
+
items[item["ref"]] = item
|
|
455
|
+
for ref, raw in user["items"].items():
|
|
456
|
+
item = self._validate_item(raw, allow_origin=True)
|
|
457
|
+
if item["ref"] != ref or item["package_id"] != LOCAL_PACKAGE:
|
|
458
|
+
raise CorpusStoreError("personal item identity mismatch")
|
|
459
|
+
items[ref] = item
|
|
460
|
+
if host:
|
|
461
|
+
suppressed = set(user.get("learning_suppressions", {}).get(host, []))
|
|
462
|
+
for event in self._learning_events(host):
|
|
463
|
+
suppressed_event = _digest(event) in suppressed
|
|
464
|
+
if suppressed_event and not include_suppressed:
|
|
465
|
+
continue
|
|
466
|
+
item = self._learning_item(host, event)
|
|
467
|
+
if item is not None:
|
|
468
|
+
if suppressed_event:
|
|
469
|
+
item["active"] = False
|
|
470
|
+
items[item["ref"]] = self._validate_item(item, allow_origin=True)
|
|
471
|
+
for ref, override in user["overrides"].items():
|
|
472
|
+
if ref not in items:
|
|
473
|
+
continue
|
|
474
|
+
base_digest = override.get("base_digest")
|
|
475
|
+
patch = override.get("patch")
|
|
476
|
+
if not isinstance(base_digest, str) or not isinstance(patch, dict):
|
|
477
|
+
raise CorpusStoreError(f"invalid override for {ref}")
|
|
478
|
+
# An upstream change to the same base is not silently merged.
|
|
479
|
+
if _digest(items[ref]) != base_digest:
|
|
480
|
+
items[ref]["conflict"] = {"base_digest": base_digest, "current_digest": _digest(items[ref])}
|
|
481
|
+
continue
|
|
482
|
+
merged = {**items[ref], **patch}
|
|
483
|
+
items[ref] = self._validate_item(merged, allow_origin=True)
|
|
484
|
+
for ref in user["tombstones"]:
|
|
485
|
+
if include_suppressed and self._learning_host_from_ref(ref) == host and ref in items:
|
|
486
|
+
items[ref]["active"] = False
|
|
487
|
+
else:
|
|
488
|
+
items.pop(ref, None)
|
|
489
|
+
return [self._normalize_content(item, allow_legacy=True) for item in items.values()], inventory, defaults, baseline_ref
|
|
490
|
+
|
|
491
|
+
def _resolve_promotions(
|
|
492
|
+
self, selected: list[dict[str, Any]], user: dict[str, Any], host: str, baseline_ref: str
|
|
493
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
494
|
+
"""Suppress only a source event proven replaced in this exact snapshot."""
|
|
495
|
+
promotions = self._baseline_promotions(baseline_ref)
|
|
496
|
+
by_id: dict[str, list[dict[str, Any]]] = {}
|
|
497
|
+
warnings: list[dict[str, Any]] = []
|
|
498
|
+
for row in promotions["promotions"]:
|
|
499
|
+
if not isinstance(row, dict):
|
|
500
|
+
warnings.append({"reason": "invalid_promotion_row"})
|
|
501
|
+
continue
|
|
502
|
+
required = {"learning_id", "host", "source_digest", "target_ref", "target_digest"}
|
|
503
|
+
if not required <= set(row):
|
|
504
|
+
# v2 rows are audience claims only. They must never cause a
|
|
505
|
+
# source deletion merely because an anchor happens to match.
|
|
506
|
+
if isinstance(row.get("learning_id"), str):
|
|
507
|
+
warnings.append({"ref": f"@local/learnings-{host}:{row['learning_id']}",
|
|
508
|
+
"reason": "promotion_mapping_incomplete"})
|
|
509
|
+
continue
|
|
510
|
+
if all(isinstance(row.get(field), str) for field in required):
|
|
511
|
+
by_id.setdefault(row["learning_id"], []).append(row)
|
|
512
|
+
else:
|
|
513
|
+
warnings.append({"reason": "invalid_promotion_row"})
|
|
514
|
+
chosen = {item["ref"]: item for item in selected}
|
|
515
|
+
retained: list[dict[str, Any]] = []
|
|
516
|
+
for item in selected:
|
|
517
|
+
if item.get("origin", {}).get("type") != "learning" or item.get("origin", {}).get("host") != host:
|
|
518
|
+
retained.append(item)
|
|
519
|
+
continue
|
|
520
|
+
learning_id = item["item_id"]
|
|
521
|
+
event = next((event for event in self._learning_events(host)
|
|
522
|
+
if (event.get("learning_id") or event.get("id")) == learning_id), None)
|
|
523
|
+
candidates = [row for row in by_id.get(learning_id, [])
|
|
524
|
+
if row["host"] == host and event is not None and row["source_digest"] == _digest(event)]
|
|
525
|
+
if not candidates:
|
|
526
|
+
retained.append(item)
|
|
527
|
+
continue
|
|
528
|
+
row = candidates[0]
|
|
529
|
+
target = chosen.get(row["target_ref"])
|
|
530
|
+
if target is None:
|
|
531
|
+
retained.append(item)
|
|
532
|
+
continue
|
|
533
|
+
if row["target_digest"] != _digest(target):
|
|
534
|
+
warnings.append({"ref": item["ref"], "reason": "promotion_target_digest_mismatch"})
|
|
535
|
+
retained.append(item)
|
|
536
|
+
continue
|
|
537
|
+
member = row.get("target_member")
|
|
538
|
+
if row.get("exclusive") is True and target["body"] == item["body"]:
|
|
539
|
+
continue
|
|
540
|
+
if isinstance(member, str) and target.get("members", {}).get(member) == item["body"]:
|
|
541
|
+
baseline_inventory, _defaults = self._read_baseline(baseline_ref)
|
|
542
|
+
original = next((raw for raw in baseline_inventory["items"] if raw.get("ref") == row["target_ref"]), None)
|
|
543
|
+
if not isinstance(original, dict) or not isinstance(original.get("members"), dict):
|
|
544
|
+
raise ValidationError(f"learning_rebase_conflict: {item['ref']}")
|
|
545
|
+
if set(original["members"]) - set(target["members"]):
|
|
546
|
+
raise ValidationError(f"learning_rebase_conflict: {item['ref']}")
|
|
547
|
+
continue
|
|
548
|
+
raise ValidationError(f"learning_rebase_conflict: {item['ref']}")
|
|
549
|
+
return retained, warnings
|
|
550
|
+
|
|
551
|
+
def _authoring_revision(self, runtime: dict[str, Any], user: dict[str, Any]) -> str:
|
|
552
|
+
learning = {}
|
|
553
|
+
for host in ("claude", "codex"):
|
|
554
|
+
events = self._learning_events(host)
|
|
555
|
+
learning[host] = _digest(events)
|
|
556
|
+
return _digest({
|
|
557
|
+
"selected_baseline_ref": runtime.get("selected_baseline_ref"),
|
|
558
|
+
"last_successful_install_ref": runtime.get("last_successful_install_ref"),
|
|
559
|
+
"user": user, "learning": learning,
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
# ---- validation ---------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
def _validate_item(self, raw: Any, *, allow_origin: bool = False) -> dict[str, Any]:
|
|
565
|
+
if not isinstance(raw, dict):
|
|
566
|
+
raise ValidationError("item must be an object")
|
|
567
|
+
unknown = set(raw) - ITEM_FIELDS - {"conflict", "content_conflict"}
|
|
568
|
+
if unknown:
|
|
569
|
+
raise ValidationError(f"unknown item fields: {', '.join(sorted(unknown))}")
|
|
570
|
+
runtime = set(raw) & RUNTIME_FIELDS
|
|
571
|
+
if runtime:
|
|
572
|
+
raise ValidationError(f"runtime-owned item fields: {', '.join(sorted(runtime))}")
|
|
573
|
+
item = _copy_json(raw)
|
|
574
|
+
required = ("ref", "package_id", "item_id", "title", "body", "surface", "tier", "domains", "kind", "members")
|
|
575
|
+
missing = [field for field in required if field not in item]
|
|
576
|
+
if missing:
|
|
577
|
+
raise ValidationError(f"item missing fields: {', '.join(missing)}")
|
|
578
|
+
for field in ("ref", "package_id", "item_id", "title", "tier"):
|
|
579
|
+
if not isinstance(item[field], str) or not item[field]:
|
|
580
|
+
raise ValidationError(f"invalid item {field}")
|
|
581
|
+
if not isinstance(item["body"], str):
|
|
582
|
+
raise ValidationError("invalid item body")
|
|
583
|
+
if item["ref"] != f"{item['package_id']}:{item['item_id']}":
|
|
584
|
+
raise ValidationError("item ref does not match package_id and item_id")
|
|
585
|
+
catalog = self._catalog_module()
|
|
586
|
+
surfaces = getattr(catalog, "SURFACES", SURFACES)
|
|
587
|
+
kinds = getattr(catalog, "KINDS", KINDS)
|
|
588
|
+
if item["surface"] not in surfaces or item["kind"] not in kinds:
|
|
589
|
+
raise ValidationError("invalid consumption surface or kind")
|
|
590
|
+
if "hook" in item:
|
|
591
|
+
try:
|
|
592
|
+
catalog.validate_hook_binding(item["hook"])
|
|
593
|
+
except (ValueError, AttributeError) as exc:
|
|
594
|
+
raise ValidationError(f"invalid hook binding: {exc}") from exc
|
|
595
|
+
if not isinstance(item["domains"], list) or not all(isinstance(x, str) for x in item["domains"]):
|
|
596
|
+
raise ValidationError("invalid item domains")
|
|
597
|
+
if not isinstance(item["members"], dict):
|
|
598
|
+
raise ValidationError("invalid item members")
|
|
599
|
+
for path, body in item["members"].items():
|
|
600
|
+
_safe_part(path, "member path")
|
|
601
|
+
if not isinstance(body, str):
|
|
602
|
+
raise ValidationError("member body must be text")
|
|
603
|
+
if "origin" in item and not isinstance(item["origin"], dict):
|
|
604
|
+
raise ValidationError("invalid item origin")
|
|
605
|
+
if not allow_origin and "origin" in item:
|
|
606
|
+
raise ValidationError("origin is catalog-owned")
|
|
607
|
+
return item
|
|
608
|
+
|
|
609
|
+
def _validate_patch(self, patch: Any) -> dict[str, Any]:
|
|
610
|
+
if not isinstance(patch, dict) or not patch:
|
|
611
|
+
raise ValidationError("update needs a non-empty patch")
|
|
612
|
+
unknown = set(patch) - (ITEM_FIELDS - {"ref", "package_id", "item_id", "origin", "active", "learning_source"})
|
|
613
|
+
if unknown:
|
|
614
|
+
raise ValidationError(f"unknown or immutable patch fields: {', '.join(sorted(unknown))}")
|
|
615
|
+
if set(patch) & RUNTIME_FIELDS:
|
|
616
|
+
raise ValidationError("runtime-owned field in patch")
|
|
617
|
+
return _copy_json(patch)
|
|
618
|
+
|
|
619
|
+
def _validate_selection(self, selection: Any, inventory: dict[str, Any]) -> list[str] | None:
|
|
620
|
+
if selection is None:
|
|
621
|
+
return None
|
|
622
|
+
if not isinstance(selection, list) or not all(isinstance(x, str) for x in selection):
|
|
623
|
+
raise ValidationError("selection must be a list of qualified refs/domains")
|
|
624
|
+
packages = {p.get("package_id") for p in inventory.get("packages", []) if isinstance(p, dict)}
|
|
625
|
+
for value in selection:
|
|
626
|
+
if value == "all":
|
|
627
|
+
continue
|
|
628
|
+
if ":" in value:
|
|
629
|
+
continue
|
|
630
|
+
if value.startswith("@local/"):
|
|
631
|
+
continue
|
|
632
|
+
if "/" not in value or not value.startswith("@"):
|
|
633
|
+
raise ValidationError(f"selection must be fully qualified: {value!r}")
|
|
634
|
+
package = value.rsplit("/", 1)[0]
|
|
635
|
+
if package not in packages and package != LOCAL_PACKAGE and not package.startswith("@local/"):
|
|
636
|
+
raise ValidationError(f"unknown package in selection: {value!r}")
|
|
637
|
+
return sorted(set(selection))
|
|
638
|
+
|
|
639
|
+
def _normalized_install_selection(self, domains: list[str] | None, catalog: dict[str, Any]) -> list[str]:
|
|
640
|
+
"""Translate legacy bare domain inputs at the installer boundary once."""
|
|
641
|
+
if domains is None:
|
|
642
|
+
return []
|
|
643
|
+
if not isinstance(domains, list) or not all(isinstance(domain, str) for domain in domains):
|
|
644
|
+
raise ValidationError("install domains must be a list of strings")
|
|
645
|
+
packages = catalog.get("packages", [])
|
|
646
|
+
if not packages or not isinstance(packages[0], dict) or not isinstance(packages[0].get("package_id"), str):
|
|
647
|
+
raise ValidationError("catalog lacks a core package")
|
|
648
|
+
core_package = packages[0]["package_id"]
|
|
649
|
+
normalized = [domain if domain == "all" or domain.startswith("@") else f"{core_package}/{domain}" for domain in domains]
|
|
650
|
+
return self._validate_selection(normalized, catalog) or []
|
|
651
|
+
|
|
652
|
+
@staticmethod
|
|
653
|
+
def _effective_selection(user: dict[str, Any], defaults: dict[str, Any], requested: list[str] | None) -> list[str] | None:
|
|
654
|
+
if requested is not None:
|
|
655
|
+
return requested
|
|
656
|
+
if user.get("selection") is not None:
|
|
657
|
+
return user["selection"]
|
|
658
|
+
return defaults.get("selection")
|
|
659
|
+
|
|
660
|
+
def _validate_snapshot_selection(self, selection: list[str] | None, inventory: dict[str, Any], items: list[dict[str, Any]]) -> None:
|
|
661
|
+
if selection is None:
|
|
662
|
+
return
|
|
663
|
+
if not isinstance(selection, list) or not all(isinstance(value, str) for value in selection):
|
|
664
|
+
raise ValidationError("selection must be a list")
|
|
665
|
+
packages = {entry["package_id"]: set((entry.get("domains") or {}).keys())
|
|
666
|
+
for entry in inventory.get("packages", []) if isinstance(entry, dict) and isinstance(entry.get("package_id"), str)}
|
|
667
|
+
packages.update({LOCAL_PACKAGE: {"personal"}, "@local/learnings-claude": {"personal"},
|
|
668
|
+
"@local/learnings-codex": {"personal"}})
|
|
669
|
+
refs = {item["ref"] for item in items}
|
|
670
|
+
for value in selection:
|
|
671
|
+
if value == "all":
|
|
672
|
+
continue
|
|
673
|
+
if ":" in value:
|
|
674
|
+
if value not in refs:
|
|
675
|
+
raise ValidationError(f"unknown corpus selection ref: {value}")
|
|
676
|
+
continue
|
|
677
|
+
if value in packages:
|
|
678
|
+
continue
|
|
679
|
+
if not value.startswith("@") or "/" not in value:
|
|
680
|
+
raise ValidationError(f"selection must be package/domain-qualified: {value!r}")
|
|
681
|
+
package, domain = value.rsplit("/", 1)
|
|
682
|
+
if package not in packages or domain not in packages[package]:
|
|
683
|
+
raise ValidationError(f"unknown corpus selection domain: {value}")
|
|
684
|
+
|
|
685
|
+
def _validate_candidate_projection(self, runtime: dict[str, Any], user: dict[str, Any]) -> None:
|
|
686
|
+
"""Use the real compiler as a plan validator without publishing output."""
|
|
687
|
+
catalog = self._catalog_module()
|
|
688
|
+
try:
|
|
689
|
+
with tempfile.TemporaryDirectory(prefix="agent-bios-plan-") as temp:
|
|
690
|
+
for host in ("claude", "codex"):
|
|
691
|
+
items, _inventory, defaults, _baseline = self._effective_items(runtime, user, host=host)
|
|
692
|
+
active = [item for item in items if item.get("active", True) is not False]
|
|
693
|
+
selection = self._effective_selection(user, defaults, None)
|
|
694
|
+
self._validate_snapshot_selection(selection, _inventory, active)
|
|
695
|
+
selected = self._selected_items(active, selection)
|
|
696
|
+
self._require_resolved(selected)
|
|
697
|
+
catalog.compile_items(_copy_json(selected), Path(temp) / host, host)
|
|
698
|
+
except Exception as exc:
|
|
699
|
+
# The catalog names the concrete member/ref; retain that actionable
|
|
700
|
+
# evidence but keep the manager's public validation contract stable.
|
|
701
|
+
raise ValidationError(f"candidate projection is invalid: {exc}") from exc
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def _require_resolved(items: list[dict[str, Any]]) -> None:
|
|
705
|
+
conflicts = [item["ref"] for item in items if item.get("conflict") or item.get("content_conflict")]
|
|
706
|
+
if conflicts:
|
|
707
|
+
raise ValidationError("unresolved corpus conflict: " + ", ".join(sorted(conflicts)))
|
|
708
|
+
|
|
709
|
+
def _rebase_overlays(self, old_inventory: dict[str, Any], new_inventory: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
|
710
|
+
"""Three-way field/member comparison shared by forward and reverse switches."""
|
|
711
|
+
result = _copy_json(user)
|
|
712
|
+
old_items = {item["ref"]: item for item in old_inventory.get("items", []) if isinstance(item, dict)}
|
|
713
|
+
new_items = {item["ref"]: item for item in new_inventory.get("items", []) if isinstance(item, dict)}
|
|
714
|
+
conflicts: list[str] = []
|
|
715
|
+
for ref, override in list(result["overrides"].items()):
|
|
716
|
+
learning_host = self._learning_host_from_ref(ref)
|
|
717
|
+
if learning_host is not None:
|
|
718
|
+
event = next((event for event in self._learning_events(learning_host)
|
|
719
|
+
if ref == f"@local/learnings-{learning_host}:{event['learning_id']}"), None)
|
|
720
|
+
if event is None or override.get("base_digest") != _digest(self._learning_item(learning_host, event)):
|
|
721
|
+
conflicts.append(ref)
|
|
722
|
+
continue
|
|
723
|
+
old, new = old_items.get(ref), new_items.get(ref)
|
|
724
|
+
if old is None or not isinstance(override, dict):
|
|
725
|
+
conflicts.append(ref)
|
|
726
|
+
continue
|
|
727
|
+
if override.get("base_digest") != _digest(old) or not isinstance(override.get("patch"), dict):
|
|
728
|
+
conflicts.append(ref)
|
|
729
|
+
continue
|
|
730
|
+
base = self._normalize_content(old, allow_legacy=True)
|
|
731
|
+
personal = self._normalize_content({**old, **override["patch"]}, allow_legacy=True)
|
|
732
|
+
if personal == base:
|
|
733
|
+
result["overrides"].pop(ref, None)
|
|
734
|
+
continue
|
|
735
|
+
if new is None:
|
|
736
|
+
conflicts.append(ref)
|
|
737
|
+
continue
|
|
738
|
+
target = self._normalize_content(new, allow_legacy=True)
|
|
739
|
+
self._require_resolved([base, target, personal])
|
|
740
|
+
merged = _copy_json(target)
|
|
741
|
+
missing = object()
|
|
742
|
+
for field in ITEM_FIELDS - {"body"}:
|
|
743
|
+
if field == "members":
|
|
744
|
+
output = dict(target["members"])
|
|
745
|
+
for member in set(base["members"]) | set(personal["members"]) | set(target["members"]):
|
|
746
|
+
prior = base["members"].get(member, missing)
|
|
747
|
+
mine = personal["members"].get(member, missing)
|
|
748
|
+
theirs = target["members"].get(member, missing)
|
|
749
|
+
if mine == prior:
|
|
750
|
+
continue
|
|
751
|
+
if theirs != prior and mine != theirs:
|
|
752
|
+
conflicts.append(f"{ref} members/{member}")
|
|
753
|
+
elif mine is missing:
|
|
754
|
+
output.pop(member, None)
|
|
755
|
+
else:
|
|
756
|
+
output[member] = mine
|
|
757
|
+
merged[field] = output
|
|
758
|
+
else:
|
|
759
|
+
prior, mine, theirs = base.get(field, missing), personal.get(field, missing), target.get(field, missing)
|
|
760
|
+
if mine == prior:
|
|
761
|
+
continue
|
|
762
|
+
if theirs != prior and mine != theirs:
|
|
763
|
+
conflicts.append(f"{ref} {field}")
|
|
764
|
+
elif mine is missing:
|
|
765
|
+
merged.pop(field, None)
|
|
766
|
+
else:
|
|
767
|
+
merged[field] = mine
|
|
768
|
+
# body is a view of the merged primary member, not a second merge input.
|
|
769
|
+
merged.pop("body", None)
|
|
770
|
+
merged = self._normalize_content(merged)
|
|
771
|
+
patch = {field: value for field, value in merged.items()
|
|
772
|
+
if field in ITEM_FIELDS and value != new.get(field)}
|
|
773
|
+
if patch:
|
|
774
|
+
result["overrides"][ref] = {"base_digest": _digest(new), "patch": patch}
|
|
775
|
+
else:
|
|
776
|
+
result["overrides"].pop(ref, None)
|
|
777
|
+
if conflicts:
|
|
778
|
+
raise ValidationError("baseline_update_conflict: " + ", ".join(sorted(conflicts)))
|
|
779
|
+
return result
|
|
780
|
+
|
|
781
|
+
# ---- public read API ----------------------------------------------------
|
|
782
|
+
|
|
783
|
+
def install(self, domains: list[str] | None = None) -> dict[str, Any]:
|
|
784
|
+
"""Install one immutable validated baseline tuple without touching user data."""
|
|
785
|
+
with self._lock():
|
|
786
|
+
return self.commit_install(self.prepare_install(domains))
|
|
787
|
+
|
|
788
|
+
def prepare_install(self, domains: list[str] | None = None) -> dict[str, Any]:
|
|
789
|
+
"""Stage a validated baseline and source plan without advancing any pointer."""
|
|
790
|
+
with self._lock():
|
|
791
|
+
self._recover_locked()
|
|
792
|
+
catalog = self._catalog_module().load_catalog(self.repo)
|
|
793
|
+
if not isinstance(catalog, dict) or catalog.get("schema_version") != SCHEMA_VERSION:
|
|
794
|
+
raise ValidationError("catalog schema mismatch")
|
|
795
|
+
raw_items = catalog.get("items")
|
|
796
|
+
if not isinstance(raw_items, list) or not raw_items:
|
|
797
|
+
raise ValidationError("catalog inventory is empty")
|
|
798
|
+
items = [self._validate_item(item, allow_origin=True) for item in raw_items]
|
|
799
|
+
if len({item["ref"] for item in items}) != len(items):
|
|
800
|
+
raise ValidationError("catalog has duplicate corpus refs")
|
|
801
|
+
defaults = {"schema_version": SCHEMA_VERSION,
|
|
802
|
+
"selection": self._normalized_install_selection(domains, catalog) +
|
|
803
|
+
[LOCAL_PACKAGE, "@local/learnings-claude", "@local/learnings-codex"]}
|
|
804
|
+
promotion_path = self.repo / "learn" / "promotions.json"
|
|
805
|
+
promotions = _json_read(promotion_path, {"version": 0, "promotions": []})
|
|
806
|
+
if not isinstance(promotions, dict) or not isinstance(promotions.get("promotions"), list):
|
|
807
|
+
raise ValidationError("promotion manifest is invalid")
|
|
808
|
+
compiler = self.repo / "compose" / "corpus_catalog.py"
|
|
809
|
+
tuple_data = {
|
|
810
|
+
"schema_version": SCHEMA_VERSION, "catalog": catalog, "defaults": defaults,
|
|
811
|
+
"promotions": promotions,
|
|
812
|
+
"compiler_digest": _digest(compiler.read_bytes()) if compiler.is_file() else None,
|
|
813
|
+
}
|
|
814
|
+
baseline_ref = _digest(tuple_data)
|
|
815
|
+
root = self._baseline_dir(baseline_ref)
|
|
816
|
+
existing = root / "inventory.json"
|
|
817
|
+
if existing.exists() and _json_read(existing) != catalog:
|
|
818
|
+
raise CorpusStoreError(f"immutable baseline collision: {baseline_ref}")
|
|
819
|
+
runtime = self._runtime_state()
|
|
820
|
+
user = self._user_state()
|
|
821
|
+
prior_last = runtime.get("last_successful_install_ref")
|
|
822
|
+
next_user = user
|
|
823
|
+
adopt = runtime.get("selected_baseline_ref") == prior_last
|
|
824
|
+
if adopt and isinstance(prior_last, str) and prior_last != baseline_ref:
|
|
825
|
+
old_inventory, _old_defaults = self._read_baseline(prior_last)
|
|
826
|
+
# A conflict refuses before either the baseline pointer or the
|
|
827
|
+
# successful-install record changes.
|
|
828
|
+
next_user = self._rebase_overlays(old_inventory, catalog, user)
|
|
829
|
+
before = {"runtime": _copy_json(runtime), "user": _copy_json(user)}
|
|
830
|
+
_atomic_write(root / "inventory.json", catalog)
|
|
831
|
+
_atomic_write(root / "defaults.json", defaults)
|
|
832
|
+
_atomic_write(root / "promotions.json", promotions)
|
|
833
|
+
runtime["last_successful_install_ref"] = baseline_ref
|
|
834
|
+
if runtime.get("selected_baseline_ref") is None or adopt:
|
|
835
|
+
runtime["selected_baseline_ref"] = baseline_ref
|
|
836
|
+
self._validate_candidate_projection(runtime, next_user)
|
|
837
|
+
transaction_id = uuid.uuid4().hex
|
|
838
|
+
details = {"baseline_ref": baseline_ref, "selected_baseline_ref": runtime["selected_baseline_ref"], "items": len(items)}
|
|
839
|
+
candidate = {"transaction_id": transaction_id, "before": before,
|
|
840
|
+
"after": {"runtime": runtime, "user": next_user}, "details": details,
|
|
841
|
+
"expected_revision": self._authoring_revision(before["runtime"], before["user"])}
|
|
842
|
+
self._write_transaction(transaction_id, {"state": "PLANNED", "kind": "install", "plan": candidate})
|
|
843
|
+
return _copy_json(candidate)
|
|
844
|
+
|
|
845
|
+
def commit_install(self, candidate: dict[str, Any]) -> dict[str, Any]:
|
|
846
|
+
"""Publish a staged installation; repeated recovery consumes the same journal."""
|
|
847
|
+
transaction_id = _safe_part(candidate.get("transaction_id"), "transaction id")
|
|
848
|
+
with self._lock():
|
|
849
|
+
self._recover_locked()
|
|
850
|
+
record = _json_read(self.runtime / "transactions" / transaction_id / "journal.json")
|
|
851
|
+
if (self.runtime / "private-install.json").exists() and not operation_scope_active(self.state_root):
|
|
852
|
+
raise ValidationError("managed installations must publish through agent-bios install")
|
|
853
|
+
if not isinstance(record, dict) or record.get("kind") != "install":
|
|
854
|
+
raise ValidationError("unknown installation candidate")
|
|
855
|
+
plan = record["plan"]
|
|
856
|
+
if len(candidate) > 1 and candidate != plan:
|
|
857
|
+
raise ValidationError("installation candidate differs from its recorded plan")
|
|
858
|
+
if record["state"] in {"COMMITTED", "RECOVERED_COMMITTED"}:
|
|
859
|
+
return _copy_json(plan["details"])
|
|
860
|
+
runtime, user = self._runtime_state(), self._user_state()
|
|
861
|
+
if record["state"] != "PLANNED" or self._authoring_revision(runtime, user) != plan["expected_revision"]:
|
|
862
|
+
raise StaleRevision("installation candidate is stale")
|
|
863
|
+
self._validate_candidate_projection(plan["after"]["runtime"], plan["after"]["user"])
|
|
864
|
+
record["state"] = "PREPARED"
|
|
865
|
+
self._write_transaction(transaction_id, record)
|
|
866
|
+
self._recover_locked()
|
|
867
|
+
return _copy_json(plan["details"])
|
|
868
|
+
|
|
869
|
+
def status(self) -> dict[str, Any]:
|
|
870
|
+
with self._lock():
|
|
871
|
+
pending = [entry for entry in pending_operations(self.state_root)
|
|
872
|
+
if entry["state"] == "NEEDS_RECOVERY"
|
|
873
|
+
or "resets" in Path(entry["path"]).parts
|
|
874
|
+
or _json_read(Path(entry["path"])).get("owner") == "installer"]
|
|
875
|
+
if pending:
|
|
876
|
+
return {"schema_version": SCHEMA_VERSION, "needs_recovery": pending,
|
|
877
|
+
"installed": self._runtime_state().get("last_successful_install_ref") is not None}
|
|
878
|
+
self._recover_locked()
|
|
879
|
+
runtime, user = self._runtime_state(), self._user_state()
|
|
880
|
+
revision = self._authoring_revision(runtime, user)
|
|
881
|
+
refs = list((self.runtime / "baselines").glob("*/inventory.json"))
|
|
882
|
+
defaults = self._selected_baseline(runtime)[2] if runtime.get("selected_baseline_ref") else {}
|
|
883
|
+
return {
|
|
884
|
+
"schema_version": SCHEMA_VERSION, "installed": runtime.get("last_successful_install_ref") is not None,
|
|
885
|
+
"last_successful_install_ref": runtime.get("last_successful_install_ref"),
|
|
886
|
+
"selected_baseline_ref": runtime.get("selected_baseline_ref"),
|
|
887
|
+
"revision": revision, "baseline_count": len(refs),
|
|
888
|
+
"personal_items": len(user["items"]), "overrides": len(user["overrides"]),
|
|
889
|
+
"tombstones": len(user["tombstones"]), "selection": self._effective_selection(user, defaults, None),
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
def list_items(self, include_removed: bool = True) -> list[dict[str, Any]]:
|
|
893
|
+
with self._lock():
|
|
894
|
+
self._recover_locked()
|
|
895
|
+
runtime, user = self._runtime_state(), self._user_state()
|
|
896
|
+
selected_ref, inventory, _defaults = self._selected_baseline(runtime)
|
|
897
|
+
all_items, _, _, _ = self._effective_items(runtime, user)
|
|
898
|
+
effective = {item["ref"]: item for item in all_items}
|
|
899
|
+
for host in ("claude", "codex"):
|
|
900
|
+
effective.update({item["ref"]: item for item in self._effective_items(runtime, user, host=host, include_suppressed=True)[0]})
|
|
901
|
+
source = {item["ref"]: self._validate_item(item, allow_origin=True) for item in inventory["items"]}
|
|
902
|
+
rows: list[dict[str, Any]] = []
|
|
903
|
+
for ref in sorted(set(source) | set(user["items"]) | set(effective)):
|
|
904
|
+
base = source.get(ref)
|
|
905
|
+
item = effective.get(ref)
|
|
906
|
+
removed = ref in user["tombstones"] or (item is not None and item.get("active", True) is False)
|
|
907
|
+
if removed and not include_removed:
|
|
908
|
+
continue
|
|
909
|
+
row = _copy_json(item or base or user["items"][ref])
|
|
910
|
+
row["state"] = "removed" if removed else ("conflict" if item and (item.get("conflict") or item.get("content_conflict")) else "active")
|
|
911
|
+
row["digest"] = _digest(item or base or user["items"][ref])
|
|
912
|
+
row["baseline_ref"] = selected_ref if base else None
|
|
913
|
+
rows.append(row)
|
|
914
|
+
return rows
|
|
915
|
+
|
|
916
|
+
def show(self, ref: str, view: str = "effective") -> dict[str, Any]:
|
|
917
|
+
if view not in {"effective", "installed", "change", "diff", "history"}:
|
|
918
|
+
raise ValidationError("unknown corpus view")
|
|
919
|
+
with self._lock():
|
|
920
|
+
self._recover_locked()
|
|
921
|
+
runtime, user = self._runtime_state(), self._user_state()
|
|
922
|
+
baseline_ref, inventory, _defaults = self._selected_baseline(runtime)
|
|
923
|
+
base = next((self._validate_item(x, allow_origin=True) for x in inventory["items"] if x.get("ref") == ref), None)
|
|
924
|
+
learning_host = self._learning_host_from_ref(ref)
|
|
925
|
+
effective = {x["ref"]: x for x in self._effective_items(runtime, user, host=learning_host, include_suppressed=learning_host is not None)[0]}.get(ref)
|
|
926
|
+
if base is None and ref not in user["items"] and effective is None:
|
|
927
|
+
raise ValidationError(f"unknown corpus ref: {ref}")
|
|
928
|
+
if view == "installed":
|
|
929
|
+
return {"ref": ref, "baseline_ref": baseline_ref, "item": base}
|
|
930
|
+
if view == "change":
|
|
931
|
+
return {"ref": ref, "override": user["overrides"].get(ref), "tombstone": user["tombstones"].get(ref), "personal": user["items"].get(ref)}
|
|
932
|
+
if view == "diff":
|
|
933
|
+
return {"ref": ref, "installed": base, "effective": effective, "change": user["overrides"].get(ref), "removed": ref in user["tombstones"]}
|
|
934
|
+
if view == "history":
|
|
935
|
+
return {"ref": ref, "history": self.history(ref)}
|
|
936
|
+
removed = ref in user["tombstones"] or (effective is not None and effective.get("active", True) is False)
|
|
937
|
+
state = "removed" if removed else ("conflict" if effective and (effective.get("conflict") or effective.get("content_conflict")) else "active")
|
|
938
|
+
return {"ref": ref, "item": effective, "digest": _digest(effective) if effective else None, "state": state}
|
|
939
|
+
|
|
940
|
+
# ---- plans --------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
def _next_personal_id(self, user: dict[str, Any]) -> str:
|
|
943
|
+
"""Allocate against retained sources and plans, including retired identities."""
|
|
944
|
+
issued = set(user["items"])
|
|
945
|
+
for path in (self.user_root / "history").glob("*/state.json"):
|
|
946
|
+
record = _json_read(path)
|
|
947
|
+
issued.update((record.get("user") or {}).get("items", {}))
|
|
948
|
+
for path in (self.runtime / "transactions").glob("*/journal.json"):
|
|
949
|
+
record = _json_read(path)
|
|
950
|
+
plan = record.get("plan") or {}
|
|
951
|
+
issued.update(((plan.get("after") or {}).get("user") or {}).get("items", {}))
|
|
952
|
+
if ref := (plan.get("details") or {}).get("ref"):
|
|
953
|
+
issued.add(ref)
|
|
954
|
+
for _attempt in range(16):
|
|
955
|
+
item_id = f"personal-{uuid.uuid4().hex}"
|
|
956
|
+
if f"{LOCAL_PACKAGE}:{item_id}" not in issued:
|
|
957
|
+
return item_id
|
|
958
|
+
raise CorpusStoreError("could not allocate an unused personal identity")
|
|
959
|
+
|
|
960
|
+
def _prepare_operation(self, payload: dict[str, Any], runtime: dict[str, Any], user: dict[str, Any],
|
|
961
|
+
*, allocated_item_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
|
962
|
+
if not isinstance(payload, dict):
|
|
963
|
+
raise ValidationError("plan payload must be an object")
|
|
964
|
+
op = payload.get("operation", payload.get("op"))
|
|
965
|
+
if op not in {"create", "update", "remove", "restore", "recover", "reset", "rollback", "select"}:
|
|
966
|
+
raise ValidationError("unknown corpus operation")
|
|
967
|
+
allowed = {
|
|
968
|
+
"create": {"operation", "op", "item", "package_id", "expected_revision"},
|
|
969
|
+
"update": {"operation", "op", "ref", "patch", "item_digest", "expected_revision"},
|
|
970
|
+
"remove": {"operation", "op", "ref", "item_digest", "expected_revision"},
|
|
971
|
+
"restore": {"operation", "op", "ref", "expected_revision"},
|
|
972
|
+
"recover": {"operation", "op", "ref", "expected_revision"},
|
|
973
|
+
"reset": {"operation", "op", "expected_revision"},
|
|
974
|
+
"rollback": {"operation", "op", "baseline_ref", "history_id", "expected_revision"},
|
|
975
|
+
"select": {"operation", "op", "selection", "expected_revision"},
|
|
976
|
+
}[op]
|
|
977
|
+
unknown = set(payload) - allowed
|
|
978
|
+
if unknown:
|
|
979
|
+
raise ValidationError(f"unknown plan fields: {', '.join(sorted(unknown))}")
|
|
980
|
+
current = self._authoring_revision(runtime, user)
|
|
981
|
+
given = payload.get("expected_revision")
|
|
982
|
+
if given is not None and given != current:
|
|
983
|
+
raise StaleRevision(f"expected revision {given} is stale; current is {current}")
|
|
984
|
+
next_runtime, next_user = _copy_json(runtime), _copy_json(user)
|
|
985
|
+
items, inventory, defaults, _baseline_ref = self._effective_items(runtime, user)
|
|
986
|
+
effective = {item["ref"]: item for item in items}
|
|
987
|
+
details: dict[str, Any] = {"operation": op}
|
|
988
|
+
if op == "create":
|
|
989
|
+
raw = _copy_json(payload.get("item"))
|
|
990
|
+
if not isinstance(raw, dict):
|
|
991
|
+
raise ValidationError("create needs item")
|
|
992
|
+
if set(raw) & {"item_id", "ref", "content_conflict", "conflict"}:
|
|
993
|
+
raise ValidationError("creation identity and conflict metadata are runtime-owned")
|
|
994
|
+
if allocated_item_id is None:
|
|
995
|
+
raise ValidationError("creation needs a recorded runtime-owned identity")
|
|
996
|
+
package = payload.get("package_id", raw.get("package_id", LOCAL_PACKAGE))
|
|
997
|
+
if package != LOCAL_PACKAGE:
|
|
998
|
+
raise ValidationError("V1 creation targets @local/personal")
|
|
999
|
+
raw["package_id"] = LOCAL_PACKAGE
|
|
1000
|
+
raw["item_id"] = allocated_item_id
|
|
1001
|
+
raw["ref"] = f"{LOCAL_PACKAGE}:{raw['item_id']}"
|
|
1002
|
+
raw.setdefault("surface", "requested")
|
|
1003
|
+
raw.setdefault("tier", "env-personal")
|
|
1004
|
+
raw.setdefault("domains", ["personal"])
|
|
1005
|
+
raw.setdefault("kind", "rule")
|
|
1006
|
+
if "members" not in raw:
|
|
1007
|
+
raw.setdefault("body", "")
|
|
1008
|
+
raw["members"] = {"content.md": raw["body"]}
|
|
1009
|
+
raw["primary_member"] = "content.md"
|
|
1010
|
+
elif "body" not in raw:
|
|
1011
|
+
primary = raw.get("primary_member")
|
|
1012
|
+
if not isinstance(raw["members"], dict) or not isinstance(primary, str) or primary not in raw["members"]:
|
|
1013
|
+
raise ValidationError("members-only creation needs primary_member")
|
|
1014
|
+
raw["body"] = raw["members"][primary]
|
|
1015
|
+
try:
|
|
1016
|
+
item = self._validate_item(self._normalize_content(raw, allow_legacy=True))
|
|
1017
|
+
self._require_resolved([item])
|
|
1018
|
+
if "body" in payload["item"] and item["body"] != raw["body"]:
|
|
1019
|
+
raise ValidationError("body conflicts with primary_member member content")
|
|
1020
|
+
except ValueError as exc:
|
|
1021
|
+
raise ValidationError(str(exc)) from exc
|
|
1022
|
+
if item["kind"] not in {"rule", "guide", "skill"}:
|
|
1023
|
+
raise ValidationError("V1 personal creation supports rule, guide, and skill items")
|
|
1024
|
+
if item["ref"] in effective or item["ref"] in next_user["items"]:
|
|
1025
|
+
raise ValidationError(f"corpus ref already exists: {item['ref']}")
|
|
1026
|
+
next_user["items"][item["ref"]] = item
|
|
1027
|
+
details.update({"ref": item["ref"], "item_digest": _digest(item)})
|
|
1028
|
+
elif op == "update":
|
|
1029
|
+
ref = payload.get("ref")
|
|
1030
|
+
if isinstance(ref, str) and ref not in effective:
|
|
1031
|
+
learning_host = self._learning_host_from_ref(ref)
|
|
1032
|
+
if learning_host is not None:
|
|
1033
|
+
effective = {item["ref"]: item for item in self._effective_items(runtime, user, host=learning_host, include_suppressed=True)[0]}
|
|
1034
|
+
if not isinstance(ref, str) or ref not in effective:
|
|
1035
|
+
raise ValidationError("update needs an active corpus ref")
|
|
1036
|
+
item = effective[ref]
|
|
1037
|
+
supplied_digest = payload.get("item_digest")
|
|
1038
|
+
item_digest = _digest(item)
|
|
1039
|
+
if not isinstance(supplied_digest, str) or supplied_digest != item_digest:
|
|
1040
|
+
raise StaleRevision(f"item digest for {ref} is stale")
|
|
1041
|
+
patch = self._validate_patch(payload.get("patch"))
|
|
1042
|
+
try:
|
|
1043
|
+
candidate = self._validate_item(self._catalog_module().update_content(item, patch), allow_origin=True)
|
|
1044
|
+
except ValueError as exc:
|
|
1045
|
+
raise ValidationError(str(exc)) from exc
|
|
1046
|
+
self._require_resolved([candidate])
|
|
1047
|
+
if ref in next_user["items"]:
|
|
1048
|
+
next_user["items"][ref] = candidate
|
|
1049
|
+
else:
|
|
1050
|
+
base = next((row for row in inventory["items"] if row["ref"] == ref), None)
|
|
1051
|
+
if base is None:
|
|
1052
|
+
host = self._learning_host_from_ref(ref)
|
|
1053
|
+
event = next(event for event in self._learning_events(host)
|
|
1054
|
+
if f"@local/learnings-{host}:{event['learning_id']}" == ref)
|
|
1055
|
+
base = self._learning_item(host, event)
|
|
1056
|
+
normalized_base = self._normalize_content(base, allow_legacy=True)
|
|
1057
|
+
delta = {field: value for field, value in candidate.items()
|
|
1058
|
+
if field in ITEM_FIELDS and value != normalized_base.get(field)}
|
|
1059
|
+
if delta:
|
|
1060
|
+
next_user["overrides"][ref] = {"base_digest": _digest(base), "patch": delta}
|
|
1061
|
+
else:
|
|
1062
|
+
next_user["overrides"].pop(ref, None)
|
|
1063
|
+
details.update({"ref": ref, "prior_item_digest": item_digest, "item_digest": _digest(candidate)})
|
|
1064
|
+
elif op == "remove":
|
|
1065
|
+
ref = payload.get("ref")
|
|
1066
|
+
if isinstance(ref, str) and ref not in effective:
|
|
1067
|
+
learning_host = self._learning_host_from_ref(ref)
|
|
1068
|
+
if learning_host is not None:
|
|
1069
|
+
effective = {item["ref"]: item for item in self._effective_items(runtime, user, host=learning_host, include_suppressed=True)[0]}
|
|
1070
|
+
if not isinstance(ref, str) or ref not in effective:
|
|
1071
|
+
raise ValidationError("remove needs an active corpus ref")
|
|
1072
|
+
supplied_digest = payload.get("item_digest")
|
|
1073
|
+
if supplied_digest is not None and supplied_digest != _digest(effective[ref]):
|
|
1074
|
+
raise StaleRevision(f"item digest for {ref} is stale")
|
|
1075
|
+
if ref in next_user["items"]:
|
|
1076
|
+
next_user["items"][ref]["active"] = False
|
|
1077
|
+
else:
|
|
1078
|
+
# The plan must have a stable result digest. Audit time belongs in
|
|
1079
|
+
# its journal, not in authoring state that is recomputed at Apply.
|
|
1080
|
+
next_user["tombstones"][ref] = {"base_digest": _digest(effective[ref])}
|
|
1081
|
+
details["ref"] = ref
|
|
1082
|
+
elif op == "restore":
|
|
1083
|
+
ref = payload.get("ref")
|
|
1084
|
+
if not isinstance(ref, str):
|
|
1085
|
+
raise ValidationError("restore needs corpus ref")
|
|
1086
|
+
if ref not in {x.get("ref") for x in inventory.get("items", [])}:
|
|
1087
|
+
raise ValidationError("restore is available only for selected installed baseline items")
|
|
1088
|
+
next_user["overrides"].pop(ref, None)
|
|
1089
|
+
next_user["tombstones"].pop(ref, None)
|
|
1090
|
+
details["ref"] = ref
|
|
1091
|
+
elif op == "recover":
|
|
1092
|
+
ref = payload.get("ref")
|
|
1093
|
+
learning_host = self._learning_host_from_ref(ref) if isinstance(ref, str) else None
|
|
1094
|
+
if learning_host is not None:
|
|
1095
|
+
event = next((event for event in self._learning_events(learning_host)
|
|
1096
|
+
if f"@local/learnings-{learning_host}:{event['learning_id']}" == ref), None)
|
|
1097
|
+
if event is None:
|
|
1098
|
+
raise ValidationError("recover needs personal corpus ref")
|
|
1099
|
+
next_user["tombstones"].pop(ref, None)
|
|
1100
|
+
suppressions = next_user.setdefault("learning_suppressions", {}).setdefault(learning_host, [])
|
|
1101
|
+
if _digest(event) in suppressions:
|
|
1102
|
+
suppressions.remove(_digest(event))
|
|
1103
|
+
elif not isinstance(ref, str) or ref not in next_user["items"]:
|
|
1104
|
+
raise ValidationError("recover needs personal corpus ref")
|
|
1105
|
+
else:
|
|
1106
|
+
next_user["items"][ref].pop("active", None)
|
|
1107
|
+
details["ref"] = ref
|
|
1108
|
+
elif op == "select":
|
|
1109
|
+
selection = self._validate_selection(payload.get("selection"), inventory)
|
|
1110
|
+
next_user["selection"] = selection
|
|
1111
|
+
details["selection"] = selection
|
|
1112
|
+
elif op == "reset":
|
|
1113
|
+
suppressions = {host: [_digest(event) for event in self._learning_events(host)]
|
|
1114
|
+
for host in ("claude", "codex")}
|
|
1115
|
+
next_user = self._empty_user()
|
|
1116
|
+
next_user["learning_suppressions"] = suppressions
|
|
1117
|
+
latest = next_runtime.get("last_successful_install_ref")
|
|
1118
|
+
if not latest:
|
|
1119
|
+
raise CorpusStoreError("cannot reset before install")
|
|
1120
|
+
next_runtime["selected_baseline_ref"] = latest
|
|
1121
|
+
_inv, latest_defaults = self._read_baseline(latest)
|
|
1122
|
+
next_user["selection"] = latest_defaults.get("selection")
|
|
1123
|
+
details["baseline_ref"] = latest
|
|
1124
|
+
elif op == "rollback":
|
|
1125
|
+
history_id = payload.get("history_id")
|
|
1126
|
+
baseline_ref = payload.get("baseline_ref")
|
|
1127
|
+
if history_id is not None:
|
|
1128
|
+
_safe_part(history_id, "history id")
|
|
1129
|
+
record = _json_read(self.user_root / "history" / str(history_id) / "state.json")
|
|
1130
|
+
if not isinstance(record, dict) or "user" not in record or "runtime" not in record:
|
|
1131
|
+
raise ValidationError("unknown rollback history")
|
|
1132
|
+
next_user = record["user"]
|
|
1133
|
+
# History rollback replays authoring state but preserves the current successful-install pointer.
|
|
1134
|
+
next_runtime["selected_baseline_ref"] = record["runtime"].get("selected_baseline_ref")
|
|
1135
|
+
self._read_baseline(next_runtime["selected_baseline_ref"])
|
|
1136
|
+
details["history_id"] = history_id
|
|
1137
|
+
else:
|
|
1138
|
+
if not isinstance(baseline_ref, str):
|
|
1139
|
+
raise ValidationError("rollback needs baseline_ref or history_id")
|
|
1140
|
+
target, _target_defaults = self._read_baseline(baseline_ref)
|
|
1141
|
+
next_user = self._rebase_overlays(inventory, target, next_user)
|
|
1142
|
+
next_runtime["selected_baseline_ref"] = baseline_ref
|
|
1143
|
+
details["baseline_ref"] = baseline_ref
|
|
1144
|
+
return next_runtime, next_user, details
|
|
1145
|
+
|
|
1146
|
+
def plan(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
1147
|
+
with self._lock():
|
|
1148
|
+
self._recover_locked()
|
|
1149
|
+
runtime, user = self._runtime_state(), self._user_state()
|
|
1150
|
+
before = self._authoring_revision(runtime, user)
|
|
1151
|
+
allocated = self._next_personal_id(user) if isinstance(payload, dict) and payload.get("operation", payload.get("op")) == "create" else None
|
|
1152
|
+
next_runtime, next_user, details = self._prepare_operation(payload, runtime, user, allocated_item_id=allocated)
|
|
1153
|
+
self._validate_candidate_projection(next_runtime, next_user)
|
|
1154
|
+
after = self._authoring_revision(next_runtime, next_user)
|
|
1155
|
+
plan_id = uuid.uuid4().hex
|
|
1156
|
+
plan = {
|
|
1157
|
+
"schema_version": SCHEMA_VERSION, "plan_id": plan_id, "created_at": _utcnow(),
|
|
1158
|
+
"expected_revision": before, "result_revision": after, "payload": _copy_json(payload),
|
|
1159
|
+
"details": details, "before": {"runtime": runtime, "user": user},
|
|
1160
|
+
"after": {"runtime": next_runtime, "user": next_user},
|
|
1161
|
+
}
|
|
1162
|
+
if allocated is not None:
|
|
1163
|
+
plan["allocated_item_id"] = allocated
|
|
1164
|
+
self._write_transaction(plan_id, {"state": "PLANNED", "plan": plan})
|
|
1165
|
+
return {key: plan[key] for key in ("schema_version", "plan_id", "expected_revision", "result_revision", "details")}
|
|
1166
|
+
|
|
1167
|
+
def apply(self, plan_id: str, expected_revision: str | None = None) -> dict[str, Any]:
|
|
1168
|
+
_safe_part(plan_id, "plan id")
|
|
1169
|
+
with self._lock():
|
|
1170
|
+
self._recover_locked()
|
|
1171
|
+
journal_path = self.runtime / "transactions" / plan_id / "journal.json"
|
|
1172
|
+
journal = _json_read(journal_path)
|
|
1173
|
+
if not isinstance(journal, dict):
|
|
1174
|
+
raise ValidationError("plan is unknown")
|
|
1175
|
+
if journal.get("state") in {"COMMITTED", "RECOVERED_COMMITTED"} and journal.get("kind") != "install":
|
|
1176
|
+
return self._applied_result(journal)
|
|
1177
|
+
if journal.get("state") != "PLANNED" or journal.get("kind") == "install":
|
|
1178
|
+
raise ValidationError("plan is not ready for apply")
|
|
1179
|
+
plan = journal.get("plan")
|
|
1180
|
+
if not isinstance(plan, dict):
|
|
1181
|
+
raise CorpusStoreError("invalid plan journal")
|
|
1182
|
+
runtime, user = self._runtime_state(), self._user_state()
|
|
1183
|
+
current = self._authoring_revision(runtime, user)
|
|
1184
|
+
expected = expected_revision if expected_revision is not None else plan.get("expected_revision")
|
|
1185
|
+
if expected != current or plan.get("expected_revision") != current:
|
|
1186
|
+
raise StaleRevision(f"plan {plan_id} is stale; current revision is {current}")
|
|
1187
|
+
# Recalculate from payload under the lock; journal after-state is a preview, not authority.
|
|
1188
|
+
allocated = plan.get("allocated_item_id")
|
|
1189
|
+
if allocated is None and plan["payload"].get("operation", plan["payload"].get("op")) == "create":
|
|
1190
|
+
# Persisted pre-allocation plans already resolved their identity in after-state.
|
|
1191
|
+
ref = plan.get("details", {}).get("ref")
|
|
1192
|
+
allocated = ref.split(":", 1)[1] if isinstance(ref, str) and ref.startswith(LOCAL_PACKAGE + ":") else None
|
|
1193
|
+
next_runtime, next_user, details = self._prepare_operation(plan["payload"], runtime, user, allocated_item_id=allocated)
|
|
1194
|
+
self._validate_candidate_projection(next_runtime, next_user)
|
|
1195
|
+
result = self._authoring_revision(next_runtime, next_user)
|
|
1196
|
+
if result != plan.get("result_revision"):
|
|
1197
|
+
raise CorpusStoreError("plan result changed during apply")
|
|
1198
|
+
prepared = {"state": "PREPARED", "plan": plan, "prior_revision": current, "prepared_at": _utcnow()}
|
|
1199
|
+
history_id = f"{prepared['prepared_at'].replace(':', '').replace('+00:00', 'Z')}-{plan_id[:12]}"
|
|
1200
|
+
prepared["history_id"] = history_id
|
|
1201
|
+
self._write_transaction(plan_id, prepared)
|
|
1202
|
+
_atomic_write(self.user_root / "history" / history_id / "state.json", {"runtime": runtime, "user": user, "revision": current})
|
|
1203
|
+
if details.get("operation") == "reset":
|
|
1204
|
+
_atomic_write(self.user_root / "trash" / history_id / "state.json", {"runtime": runtime, "user": user, "revision": current})
|
|
1205
|
+
_atomic_write(self._user_state_path, next_user)
|
|
1206
|
+
_atomic_write(self._runtime_state_path, next_runtime)
|
|
1207
|
+
committed = {"state": "COMMITTED", "plan": plan, "history_id": history_id, "revision": result, "committed_at": _utcnow()}
|
|
1208
|
+
self._write_transaction(plan_id, committed)
|
|
1209
|
+
return {"plan_id": plan_id, "history_id": history_id, "revision": result, "details": details}
|
|
1210
|
+
|
|
1211
|
+
@staticmethod
|
|
1212
|
+
def _applied_result(journal: dict[str, Any]) -> dict[str, Any]:
|
|
1213
|
+
plan = journal["plan"]
|
|
1214
|
+
return {"plan_id": plan["plan_id"], "history_id": journal["history_id"],
|
|
1215
|
+
"revision": plan["result_revision"], "details": plan["details"]}
|
|
1216
|
+
|
|
1217
|
+
# ---- immutable snapshots and history -----------------------------------
|
|
1218
|
+
|
|
1219
|
+
def _selected_items(self, items: list[dict[str, Any]], selection: list[str] | None) -> list[dict[str, Any]]:
|
|
1220
|
+
if selection and "all" in selection:
|
|
1221
|
+
return items
|
|
1222
|
+
selected: list[dict[str, Any]] = []
|
|
1223
|
+
for item in items:
|
|
1224
|
+
if item.get("tier") in {"core", "infra"}:
|
|
1225
|
+
selected.append(item)
|
|
1226
|
+
continue
|
|
1227
|
+
if not selection:
|
|
1228
|
+
continue
|
|
1229
|
+
if item["ref"] in selection or item["package_id"] in selection:
|
|
1230
|
+
selected.append(item)
|
|
1231
|
+
continue
|
|
1232
|
+
if any(f"{item['package_id']}/{domain}" in selection for domain in item.get("domains", [])):
|
|
1233
|
+
selected.append(item)
|
|
1234
|
+
return selected
|
|
1235
|
+
|
|
1236
|
+
def snapshot(self, host: str, selection: list[str] | None = None, dry_run: bool = False,
|
|
1237
|
+
native: bool = False) -> dict[str, Any]:
|
|
1238
|
+
"""Compose an immutable activated-session snapshot.
|
|
1239
|
+
|
|
1240
|
+
A dry run has no durable write path: it compiles in an OS temporary
|
|
1241
|
+
directory and rewrites only the returned private paths to their future
|
|
1242
|
+
content-addressed destination. The ContentRef is therefore the same
|
|
1243
|
+
value Apply/launch will later publish.
|
|
1244
|
+
"""
|
|
1245
|
+
_host(host)
|
|
1246
|
+
if not isinstance(native, bool):
|
|
1247
|
+
raise ValidationError("native activation must be boolean")
|
|
1248
|
+
if dry_run and not self._runtime_state_path.is_file():
|
|
1249
|
+
raise CorpusStoreError("no installed baseline; run install first")
|
|
1250
|
+
lock = transaction_lock(self.state_root) if dry_run else self._lock()
|
|
1251
|
+
with lock:
|
|
1252
|
+
guard_pending(self.state_root)
|
|
1253
|
+
if dry_run and pending_operations(self.state_root):
|
|
1254
|
+
raise CorpusStoreError("source transaction needs recovery before snapshot preview")
|
|
1255
|
+
if not dry_run:
|
|
1256
|
+
self._recover_locked()
|
|
1257
|
+
runtime, user = self._runtime_state(), self._user_state()
|
|
1258
|
+
items, inventory, defaults, baseline_ref = self._effective_items(runtime, user, host=host)
|
|
1259
|
+
active = [item for item in items if item.get("active", True) is not False]
|
|
1260
|
+
effective_selection = self._effective_selection(user, defaults, selection)
|
|
1261
|
+
self._validate_snapshot_selection(effective_selection, inventory, active)
|
|
1262
|
+
selected = self._selected_items(active, effective_selection)
|
|
1263
|
+
self._require_resolved(selected)
|
|
1264
|
+
selected, promotion_warnings = self._resolve_promotions(selected, user, host, baseline_ref)
|
|
1265
|
+
bootstrap_path = self.repo / "compose" / "bootstrap" / "SKILL.md"
|
|
1266
|
+
if not bootstrap_path.is_file():
|
|
1267
|
+
raise CorpusStoreError("private management bootstrap is missing")
|
|
1268
|
+
catalog_path = self.repo / "compose" / "corpus_catalog.py"
|
|
1269
|
+
inputs = {
|
|
1270
|
+
"schema_version": SCHEMA_VERSION, "host": host, "baseline_ref": baseline_ref,
|
|
1271
|
+
"selection": effective_selection,
|
|
1272
|
+
"selection_digest": _digest(effective_selection),
|
|
1273
|
+
"authoring_revision": self._authoring_revision(runtime, user),
|
|
1274
|
+
"item_digests": {item["ref"]: _digest(item) for item in sorted(selected, key=lambda x: x["ref"])},
|
|
1275
|
+
"learning_digest": _digest(self._learning_events(host)),
|
|
1276
|
+
"promotion_digest": _digest(self._baseline_promotions(baseline_ref)),
|
|
1277
|
+
"compiler_digest": _digest(catalog_path.read_bytes()) if catalog_path.is_file() else None,
|
|
1278
|
+
"store_schema_digest": _digest(Path(__file__).read_bytes()),
|
|
1279
|
+
"bootstrap_digest": _digest(bootstrap_path.read_bytes()),
|
|
1280
|
+
}
|
|
1281
|
+
if native:
|
|
1282
|
+
import sys
|
|
1283
|
+
inputs["native"] = True
|
|
1284
|
+
inputs["native_python"] = sys.executable
|
|
1285
|
+
content_ref = _digest(inputs)
|
|
1286
|
+
root = self.sessions / "snapshots" / content_ref
|
|
1287
|
+
_reject_symlink_path(self.sessions)
|
|
1288
|
+
_reject_symlink_path(self.sessions / "snapshots")
|
|
1289
|
+
catalog = self._catalog_module()
|
|
1290
|
+
if not hasattr(catalog, "compile_items"):
|
|
1291
|
+
raise CorpusStoreError("corpus catalog has no compile_items")
|
|
1292
|
+
if not dry_run and (root / "inventory.json").exists():
|
|
1293
|
+
verified = verify_snapshot(root, content_ref)
|
|
1294
|
+
if verified["inputs"] != inputs:
|
|
1295
|
+
raise CorpusStoreError(f"immutable snapshot collision: {content_ref}")
|
|
1296
|
+
output = verified["output"]
|
|
1297
|
+
else:
|
|
1298
|
+
if dry_run:
|
|
1299
|
+
temp = tempfile.TemporaryDirectory(prefix="agent-bios-snapshot-")
|
|
1300
|
+
staging = Path(temp.name) / "snapshot"
|
|
1301
|
+
staging.mkdir()
|
|
1302
|
+
else:
|
|
1303
|
+
temp = None
|
|
1304
|
+
staging = root.with_name(f".{root.name}.staging-{uuid.uuid4().hex}")
|
|
1305
|
+
staging.mkdir(parents=True, exist_ok=False)
|
|
1306
|
+
try:
|
|
1307
|
+
compiled = catalog.compile_items(_copy_json(selected), staging, host, native=True) if native else catalog.compile_items(_copy_json(selected), staging, host)
|
|
1308
|
+
if not isinstance(compiled, dict) or not isinstance(compiled.get("instruction_text"), str):
|
|
1309
|
+
raise CorpusStoreError("catalog compiler returned invalid output")
|
|
1310
|
+
files = _snapshot_relative_paths(compiled.get("files"))
|
|
1311
|
+
output = {
|
|
1312
|
+
"instruction_text": compiled.get("instruction_text", ""),
|
|
1313
|
+
"files": sorted(set(files) | {"bootstrap/SKILL.md"}), "item_refs": compiled.get("item_refs", []),
|
|
1314
|
+
"unavailable": compiled.get("unavailable", []) + promotion_warnings,
|
|
1315
|
+
}
|
|
1316
|
+
assets = _snapshot_assets(compiled.get("assets", {}), files)
|
|
1317
|
+
if assets:
|
|
1318
|
+
output["assets"] = assets
|
|
1319
|
+
invocation = f"Corpus management: invoke $corpus using {root / 'bootstrap' / 'SKILL.md'}."
|
|
1320
|
+
output["instruction_text"] = output["instruction_text"].rstrip() + "\n\n" + invocation + "\n"
|
|
1321
|
+
for plugin in assets.get("claude_plugins", []):
|
|
1322
|
+
for agent_path in (staging / plugin / "agents").glob("*.md"):
|
|
1323
|
+
with agent_path.open("a", encoding="utf-8") as agent_file:
|
|
1324
|
+
agent_file.write("\n\n" + invocation + "\n")
|
|
1325
|
+
if dry_run:
|
|
1326
|
+
output["instruction_text"] = output["instruction_text"].replace(str(staging), str(root))
|
|
1327
|
+
else:
|
|
1328
|
+
bootstrap_target = staging / "bootstrap" / "SKILL.md"
|
|
1329
|
+
bootstrap_target.parent.mkdir(parents=True, exist_ok=True)
|
|
1330
|
+
bootstrap_target.write_bytes(bootstrap_path.read_bytes())
|
|
1331
|
+
_rewrite_staged_paths(staging, root)
|
|
1332
|
+
output["instruction_text"] = output["instruction_text"].replace(str(staging), str(root))
|
|
1333
|
+
_atomic_write(staging / "inventory.json", {"inputs": inputs, "items": selected})
|
|
1334
|
+
_atomic_write(staging / "output.json", output)
|
|
1335
|
+
digests = _snapshot_file_digests(staging, output["files"])
|
|
1336
|
+
_atomic_write(staging / "inventory.json", {"inputs": inputs, "items": selected, "file_digests": digests})
|
|
1337
|
+
# A directory rename is the publication point; no consumer sees a partial snapshot.
|
|
1338
|
+
if not dry_run:
|
|
1339
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
1340
|
+
try:
|
|
1341
|
+
os.replace(staging, root)
|
|
1342
|
+
except FileExistsError:
|
|
1343
|
+
if verify_snapshot(root, content_ref)["inputs"] != inputs:
|
|
1344
|
+
raise CorpusStoreError(f"immutable snapshot collision: {content_ref}")
|
|
1345
|
+
finally:
|
|
1346
|
+
if temp is not None:
|
|
1347
|
+
temp.cleanup()
|
|
1348
|
+
elif staging.exists():
|
|
1349
|
+
import shutil
|
|
1350
|
+
shutil.rmtree(staging)
|
|
1351
|
+
return {"content_ref": content_ref, "path": str(root), "instruction_text": output["instruction_text"],
|
|
1352
|
+
"revision": inputs["authoring_revision"], "unavailable": output.get("unavailable", []),
|
|
1353
|
+
"assets": output.get("assets", {})}
|
|
1354
|
+
|
|
1355
|
+
def history(self, ref: str | None = None) -> list[dict[str, Any]]:
|
|
1356
|
+
root = self.user_root / "history"
|
|
1357
|
+
if not root.is_dir():
|
|
1358
|
+
return []
|
|
1359
|
+
rows: list[dict[str, Any]] = []
|
|
1360
|
+
for path in sorted(root.glob("*/state.json"), reverse=True):
|
|
1361
|
+
record = _json_read(path)
|
|
1362
|
+
if not isinstance(record, dict):
|
|
1363
|
+
continue
|
|
1364
|
+
if ref is not None:
|
|
1365
|
+
user = record.get("user", {})
|
|
1366
|
+
if ref not in user.get("items", {}) and ref not in user.get("overrides", {}) and ref not in user.get("tombstones", {}):
|
|
1367
|
+
continue
|
|
1368
|
+
rows.append({"history_id": path.parent.name, "revision": record.get("revision"), "path": str(path.parent)})
|
|
1369
|
+
return rows
|
|
1370
|
+
|
|
1371
|
+
def snapshot_inventory(self, content_ref: str) -> dict[str, Any]:
|
|
1372
|
+
"""Read a pinned snapshot by ContentRef without resolving current state."""
|
|
1373
|
+
_safe_part(content_ref, "content ref")
|
|
1374
|
+
with self._lock():
|
|
1375
|
+
self._recover_locked()
|
|
1376
|
+
root = self.sessions / "snapshots" / content_ref
|
|
1377
|
+
verified = verify_snapshot(root, content_ref)
|
|
1378
|
+
output = verified["output"]
|
|
1379
|
+
return {"content_ref": content_ref, "path": str(root), "inputs": verified["inputs"],
|
|
1380
|
+
"items": verified["items"], "files": output.get("files"),
|
|
1381
|
+
"item_refs": output.get("item_refs"), "instruction_text": output.get("instruction_text"),
|
|
1382
|
+
"unavailable": output.get("unavailable"), "assets": output.get("assets", {})}
|
|
1383
|
+
|
|
1384
|
+
def capture_learning(self, host: str, record: dict[str, Any]) -> dict[str, Any]:
|
|
1385
|
+
"""Append exact captured bytes to a host-private immutable source journal."""
|
|
1386
|
+
_host(host)
|
|
1387
|
+
if not isinstance(record, dict):
|
|
1388
|
+
raise ValidationError("learning capture must be an object")
|
|
1389
|
+
event = _copy_json(record)
|
|
1390
|
+
unknown = set(event) - LEARNING_FIELDS
|
|
1391
|
+
required = {"schema_version", "learning_id", "lesson", "domain", "created", "supporting_sessions"}
|
|
1392
|
+
if unknown or not required <= set(event):
|
|
1393
|
+
raise ValidationError("learning record must use the collector v1 schema exactly")
|
|
1394
|
+
if event["schema_version"] != 1 or not isinstance(event["learning_id"], str) or not LEARNING_ID_RE.fullmatch(event["learning_id"]):
|
|
1395
|
+
raise ValidationError("invalid collector learning identity")
|
|
1396
|
+
if not isinstance(event["lesson"], str) or not isinstance(event["domain"], str) or not isinstance(event["created"], str):
|
|
1397
|
+
raise ValidationError("invalid collector learning content")
|
|
1398
|
+
sessions = event["supporting_sessions"]
|
|
1399
|
+
if not isinstance(sessions, list) or not sessions or not all(isinstance(value, str) for value in sessions):
|
|
1400
|
+
raise ValidationError("invalid collector learning provenance")
|
|
1401
|
+
path = self.user_root / "learnings" / host / "events.jsonl"
|
|
1402
|
+
with self._lock():
|
|
1403
|
+
self._recover_locked()
|
|
1404
|
+
prior = self._learning_events(host)
|
|
1405
|
+
if any(x.get("learning_id") == event["learning_id"] for x in prior):
|
|
1406
|
+
raise ValidationError("learning_id already captured")
|
|
1407
|
+
_reject_symlink_path(path, leaf=False)
|
|
1408
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1409
|
+
# Append is atomic under the store lock and fsync makes the capture durable before return.
|
|
1410
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
1411
|
+
handle.write(_canonical(event).decode("utf-8") + "\n")
|
|
1412
|
+
handle.flush()
|
|
1413
|
+
os.fsync(handle.fileno())
|
|
1414
|
+
return {"host": host, "learning_id": event["learning_id"], "digest": _digest(event), "revision": self._authoring_revision(self._runtime_state(), self._user_state())}
|