syncmill 0.2.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.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,962 @@
1
+ """Staged, source-retaining migration between syncmill memory backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import errno
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import stat
11
+ import sys
12
+ import tempfile
13
+ from collections.abc import Iterator
14
+ from contextlib import AsyncExitStack, contextmanager
15
+ from dataclasses import asdict, dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Literal, NoReturn
19
+ from urllib.parse import urlsplit
20
+ from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
21
+
22
+ from syncmill.artifacts import (
23
+ ApplyLockBusy,
24
+ ArtifactError,
25
+ _close_apply_lock_descriptor,
26
+ _lock_file_descriptor,
27
+ _open_apply_lock_descriptor,
28
+ _unlock_file_descriptor,
29
+ )
30
+ from syncmill.config import MemoryConfig, SyncmillConfig
31
+
32
+ BackendName = Literal["memtomem", "mem0", "langgraph"]
33
+ _PERSISTENT_PREFIX = "agent-runtime:"
34
+ _SCRATCH_PREFIX = "board-run:"
35
+ _SCHEMA_VERSION = 2
36
+ _STORAGE_BINDING_VERSION = 1
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class MigrationRecord:
41
+ source_id: str
42
+ namespace: str
43
+ content: str
44
+ tags: list[str]
45
+ created_at: str | None
46
+ lineage_key: str
47
+ digest: str
48
+
49
+
50
+ class MigrationError(RuntimeError):
51
+ """A load-bearing migration failure."""
52
+
53
+
54
+ def _canonical(value: Any) -> bytes:
55
+ return json.dumps(
56
+ value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
57
+ ).encode("utf-8")
58
+
59
+
60
+ def _digest(namespace: str, content: str, tags: list[str]) -> str:
61
+ return hashlib.sha256(
62
+ _canonical({"namespace": namespace, "content": content, "tags": tags})
63
+ ).hexdigest()
64
+
65
+
66
+ def _record(source: BackendName, source_id: str, namespace: str, content: str,
67
+ tags: list[str], created_at: str | None) -> MigrationRecord:
68
+ digest = _digest(namespace, content, tags)
69
+ lineage = hashlib.sha256(
70
+ _canonical({"source": source, "source_id": source_id, "digest": digest})
71
+ ).hexdigest()
72
+ return MigrationRecord(source_id, namespace, content, tags, created_at, lineage, digest)
73
+
74
+
75
+ def _aggregate(records: list[MigrationRecord]) -> str:
76
+ return hashlib.sha256(
77
+ _canonical([(r.lineage_key, r.digest) for r in records])
78
+ ).hexdigest()
79
+
80
+
81
+ def _storage_path(value: str) -> str:
82
+ if value == ":memory:":
83
+ raise MigrationError("in-memory stores cannot be migration destinations")
84
+ return str(Path(value).expanduser().resolve(strict=False))
85
+
86
+
87
+ def _mem0_locator_unchecked(config: MemoryConfig) -> dict[str, Any]:
88
+ from syncmill.memory_backend import _resolve_mem0_config
89
+
90
+ try:
91
+ _, resolved = _resolve_mem0_config(config)
92
+ except (TypeError, ValueError) as exc:
93
+ raise MigrationError(f"invalid Mem0 destination configuration: {exc}") from exc
94
+ vector_cfg = resolved["vector_store"]["config"]
95
+ if vector_cfg.get("client") is not None:
96
+ raise MigrationError(
97
+ "programmatic Mem0 clients cannot be bound to a migration destination"
98
+ )
99
+
100
+ collection = vector_cfg.get("collection_name", "syncmill")
101
+ url = vector_cfg.get("url")
102
+ host = vector_cfg.get("host")
103
+ port = vector_cfg.get("port")
104
+ https = vector_cfg.get("https")
105
+ if url and host:
106
+ raise MigrationError("Mem0 destination must use either url or host, not both")
107
+ if url:
108
+ parsed = urlsplit(str(url))
109
+ if (
110
+ parsed.scheme not in {"http", "https"}
111
+ or not parsed.hostname
112
+ or parsed.username is not None
113
+ or parsed.password is not None
114
+ or parsed.query
115
+ or parsed.fragment
116
+ ):
117
+ raise MigrationError(
118
+ "Mem0 destination url must be an uncredentialed http(s) endpoint"
119
+ )
120
+ prefix = parsed.path.rstrip("/")
121
+ vector = {
122
+ "mode": "remote",
123
+ "scheme": parsed.scheme,
124
+ "host": parsed.hostname.lower(),
125
+ "port": parsed.port or 6333,
126
+ "prefix": prefix,
127
+ "collection": collection,
128
+ }
129
+ elif host is not None:
130
+ if port is None:
131
+ raise MigrationError("Mem0 destination host requires an explicit port")
132
+ secure = https is True or (
133
+ https is None and vector_cfg.get("api_key") is not None
134
+ )
135
+ vector = {
136
+ "mode": "remote",
137
+ "scheme": "https" if secure else "http",
138
+ "host": str(host).lower(),
139
+ "port": int(port),
140
+ "prefix": "",
141
+ "collection": collection,
142
+ }
143
+ else:
144
+ if vector_cfg.get("api_key") is not None or https is not None:
145
+ raise MigrationError(
146
+ "Mem0 destination api_key/https requires an explicit url or host"
147
+ )
148
+ path = vector_cfg.get("path")
149
+ if not isinstance(path, str):
150
+ raise MigrationError("Mem0 local destination requires a persistent path")
151
+ vector = {
152
+ "mode": "local",
153
+ "path": _storage_path(path),
154
+ "collection": collection,
155
+ }
156
+ return {
157
+ "vector": vector,
158
+ # Mem0 delete writes a DELETE event here after removing the Qdrant row.
159
+ # It is therefore part of the rollback destination, not auxiliary cache.
160
+ "history_db_path": _storage_path(resolved["history_db_path"]),
161
+ }
162
+
163
+
164
+ def _mem0_locator(config: MemoryConfig) -> dict[str, Any]:
165
+ try:
166
+ return _mem0_locator_unchecked(config)
167
+ except MigrationError:
168
+ raise
169
+ except (TypeError, ValueError) as exc:
170
+ raise MigrationError("invalid Mem0 destination configuration") from exc
171
+
172
+
173
+ def _target_storage_digest(config: SyncmillConfig, backend: BackendName) -> str:
174
+ memory = _config_for(config, backend)
175
+ if backend == "memtomem":
176
+ locator = {"db_path": _storage_path(memory.db_path)}
177
+ elif backend == "langgraph":
178
+ locator = {"db_path": _storage_path(memory.langgraph.db_path)}
179
+ else:
180
+ locator = _mem0_locator(memory)
181
+ payload = {
182
+ "binding_version": _STORAGE_BINDING_VERSION,
183
+ "backend": backend,
184
+ "locator": locator,
185
+ }
186
+ return "sha256:" + hashlib.sha256(_canonical(payload)).hexdigest()
187
+
188
+
189
+ def _reject_legacy_artifact(state: dict[str, Any]) -> NoReturn:
190
+ """Refuse a pre-destination-binding (schema v1) artifact.
191
+
192
+ Called from ``_load``, so no consumer ever holds one. A v1 artifact that got
193
+ as far as writing targets cannot be recovered automatically — the plan does
194
+ not record which destination those ids belong to.
195
+ """
196
+ if state.get("targets"):
197
+ raise MigrationError(
198
+ "legacy migration artifact has no destination binding; "
199
+ "automatic recovery is disabled; use state.json targets with "
200
+ "the original destination settings for manual recovery"
201
+ )
202
+ raise MigrationError(
203
+ "legacy migration artifact has no destination binding; create a new plan"
204
+ )
205
+
206
+
207
+ def _validate_target_storage(config: SyncmillConfig, manifest: dict[str, Any]) -> None:
208
+ if manifest.get("storage_binding_version") != _STORAGE_BINDING_VERSION:
209
+ raise MigrationError("unsupported migration storage binding version")
210
+ expected = manifest.get("target_storage_digest")
211
+ actual = _target_storage_digest(config, manifest["target"])
212
+ if not isinstance(expected, str) or actual != expected:
213
+ raise MigrationError(
214
+ "migration destination storage configuration does not match the plan"
215
+ )
216
+
217
+
218
+ def migration_root() -> Path:
219
+ return Path(os.environ.get("SYNCMILL_MIGRATIONS_DIR", "~/.syncmill/migrations")).expanduser()
220
+
221
+
222
+ def _tighten_migration_root(*, create: bool) -> Path:
223
+ """Return the migrations root, private (0700).
224
+
225
+ Every artifact under it holds verbatim memory content, and ``mkdir``'s ``mode``
226
+ applies to the LEAF only — so creating an artifact with ``parents=True`` left the
227
+ root itself at the default umask. Called on the READ paths too (via ``_artifact``,
228
+ best-effort): an existing installation that only ever applies or verifies would
229
+ otherwise keep the old permissive root forever.
230
+ """
231
+ root = migration_root()
232
+ if create:
233
+ try:
234
+ root.mkdir(parents=True, exist_ok=True)
235
+ os.chmod(root, 0o700)
236
+ except OSError as exc:
237
+ # Same vocabulary as the read branch: the CLI catches only
238
+ # MigrationError, so a raw OSError here would traceback.
239
+ raise MigrationError(
240
+ f"cannot create the migrations directory ({root}): {exc}"
241
+ ) from exc
242
+ return root
243
+ try:
244
+ os.chmod(root, 0o700)
245
+ except FileNotFoundError:
246
+ # Nothing to tighten; the caller reports the missing migration itself.
247
+ pass
248
+ except OSError as exc:
249
+ # A root we cannot tighten (owned by someone else, or a filesystem that
250
+ # refuses chmod) stays readable while holding verbatim memory content —
251
+ # refuse rather than proceed under a privacy guarantee we are not keeping.
252
+ raise MigrationError(
253
+ f"cannot make the migrations directory private ({root}): {exc}"
254
+ ) from exc
255
+ return root
256
+
257
+
258
+ @contextmanager
259
+ def _migration_lock(path: Path) -> Iterator[None]:
260
+ """Serialize a whole load → operate → write sequence for one migration.
261
+
262
+ Unique temp names stop a torn ``state.json``; they do NOT stop a stale one.
263
+ ``apply``/``verify``/``rollback`` each read the full state document and rewrite
264
+ it, so two overlapping commands make the slower one publish a ``targets`` map
265
+ that predates the other's work — orphaning imported rows from any later retry or
266
+ rollback, which is exactly the data the rollback path needs.
267
+
268
+ NOT re-entrant, deliberately. ``apply`` needs a verify at the end, but it calls
269
+ ``_verify_locked`` directly inside the lock it already holds rather than
270
+ re-entering here — a re-entrancy shortcut keyed on the migration id would let a
271
+ SECOND task or thread using that same id walk straight past the lock while the
272
+ first awaits backend I/O, which is precisely the concurrency the lock exists to
273
+ stop. Uses the artifact store's cross-platform helpers so the guarantee is not
274
+ quietly absent on a platform without ``fcntl``.
275
+
276
+ Requires a LOCAL filesystem — a correctness requirement, not a nicety.
277
+ ``flock`` on a network mount is emulated at best (Linux maps it onto POSIX
278
+ locks over NFS) and silently local-only at worst, so a migrations root on a
279
+ shared mount can have two hosts both believe they hold this lock. The digest
280
+ checks do NOT contain the damage: each host passes the empty-destination scan,
281
+ each writes its own target ids, and last-writer-wins on ``state.json`` leaves
282
+ the other host's imported rows untracked — invisible to verify and unreachable
283
+ by rollback. Documented for operators in the README and both user guides.
284
+
285
+ Acquisition FAILS FAST rather than waiting. Orchestration bridges async through
286
+ one shared module-level event loop, so a blocking acquire taken on that loop's
287
+ thread wedges it: the waiter cannot yield, and the holder needs the same loop to
288
+ finish and release. Failing fast also suits the CLI, where the alternative is a
289
+ second invocation hanging with no output. Every failure becomes a
290
+ ``MigrationError`` — the only exception type the migration commands' callers
291
+ catch, so neither ``ApplyLockBusy`` nor a raw ``OSError`` escapes.
292
+ """
293
+ lock_path = path / ".lock"
294
+ try:
295
+ fd, owner_pid = _open_lock(lock_path)
296
+ except OSError as exc:
297
+ raise MigrationError(f"cannot open the migration lock: {exc}") from exc
298
+ try:
299
+ # Undo the umask masking of the 0600 the open asked for. Left at, say,
300
+ # 0400 under `umask 0200`, the file can never be reopened O_RDWR — the
301
+ # FIRST command would lock this migration out permanently.
302
+ _chmod_lock(fd, lock_path)
303
+ try:
304
+ _lock_file_descriptor(fd, blocking=False)
305
+ except ApplyLockBusy as exc:
306
+ raise MigrationError(
307
+ "another migration command is already running for this migration — "
308
+ "wait for it to finish, then retry"
309
+ ) from exc
310
+ except (OSError, ArtifactError) as exc:
311
+ raise MigrationError(f"cannot lock the migration: {exc}") from exc
312
+ try:
313
+ yield
314
+ finally:
315
+ # Only the opener unlocks: a forked child inherits the open file
316
+ # description, and unlocking there would release the PARENT's hold.
317
+ if os.getpid() == owner_pid:
318
+ try:
319
+ _unlock_file_descriptor(fd)
320
+ except OSError: # closing below releases it regardless
321
+ pass
322
+ finally:
323
+ # Same owner-pid guard as the unlock, and for the same reason: the
324
+ # artifact at-fork handler already closes inherited descriptors, so a
325
+ # child unwinding this context would either raise on a closed fd or close
326
+ # an unrelated descriptor that has since reused the number.
327
+ if os.getpid() == owner_pid:
328
+ try:
329
+ _close_apply_lock_descriptor(fd)
330
+ except OSError as exc:
331
+ # Never mask the exception that is already unwinding.
332
+ if sys.exc_info()[0] is None:
333
+ raise MigrationError(
334
+ f"cannot release the migration lock: {exc}"
335
+ ) from exc
336
+
337
+
338
+ def _open_lock(lock_path: Path) -> tuple[int, int]:
339
+ """Open the lock descriptor, registered for fork safety.
340
+
341
+ Registered because a fork-only child inherits the locked open file
342
+ description: if the parent is killed before it unlocks, that child keeps every
343
+ later migration in the fail-fast "already running" state. ``artifacts`` already
344
+ owns that bookkeeping (and clears inherited fds in its at-fork hook), so this
345
+ reuses the descriptor lifecycle — but NOT its ``_cross_process_lock`` wrapper,
346
+ whose thread-local re-entrancy would let a second coroutine on our one shared
347
+ event loop re-enter a held lock, which is the bypass round 2 removed.
348
+ """
349
+ try:
350
+ fd, owner_pid = _open_apply_lock_descriptor(lock_path)
351
+ except PermissionError:
352
+ # ONLY a permission failure means "left under-permissioned by a
353
+ # restrictive umask". Repairing on any OSError would chmod on EISDIR
354
+ # (turning a `.lock` DIRECTORY into mode 0600), on ENOSPC, on EMFILE —
355
+ # mutations unrelated to the failure, which also bury its real cause.
356
+ _require_regular_lock_path(lock_path)
357
+ os.chmod(lock_path, 0o600)
358
+ fd, owner_pid = _open_apply_lock_descriptor(lock_path)
359
+ except OSError as exc:
360
+ if exc.errno == errno.ELOOP:
361
+ # O_NOFOLLOW rejects symlinks before descriptor validation runs.
362
+ # Preserve the migration API's diagnostic without reopening them.
363
+ raise MigrationError(
364
+ f"the migration lock path is not a regular file: {lock_path}"
365
+ ) from exc
366
+ raise
367
+ _verify_lock_target(fd, lock_path)
368
+ return fd, owner_pid
369
+
370
+
371
+ def _require_regular_lock_path(lock_path: Path) -> None:
372
+ """lstat, so a repair can never follow a symlink into someone else's file."""
373
+ try:
374
+ info = os.lstat(lock_path)
375
+ except OSError as exc:
376
+ raise MigrationError(f"cannot inspect the migration lock: {exc}") from exc
377
+ if not stat.S_ISREG(info.st_mode):
378
+ raise MigrationError(
379
+ f"the migration lock path is not a regular file: {lock_path}"
380
+ )
381
+
382
+
383
+ def _verify_lock_target(fd: int, lock_path: Path) -> None:
384
+ """Check what we actually opened, on the SUCCESS path too.
385
+
386
+ A readable symlink at ``.lock`` opens fine, so validating only after a
387
+ permission failure left the fast path unguarded: we would then lock, and
388
+ ``_chmod_lock`` would change the LINK TARGET's mode — someone else's file set
389
+ to 0600. Comparing ``lstat`` (which does not follow) with ``fstat`` of the
390
+ descriptor we hold rejects a symlink and confirms the two are the same file,
391
+ so nothing swapped underneath between the open and the check."""
392
+ try:
393
+ link_info = os.lstat(lock_path)
394
+ open_info = os.fstat(fd)
395
+ except OSError as exc:
396
+ _release_lock_descriptor(fd)
397
+ raise MigrationError(f"cannot inspect the migration lock: {exc}") from exc
398
+ if (
399
+ not stat.S_ISREG(link_info.st_mode)
400
+ or (link_info.st_dev, link_info.st_ino) != (open_info.st_dev, open_info.st_ino)
401
+ ):
402
+ _release_lock_descriptor(fd)
403
+ raise MigrationError(
404
+ f"the migration lock path is not a regular file: {lock_path}"
405
+ )
406
+
407
+
408
+ def _release_lock_descriptor(fd: int) -> None:
409
+ """Close and unregister a descriptor we are abandoning before locking it."""
410
+ try:
411
+ _close_apply_lock_descriptor(fd)
412
+ except OSError:
413
+ pass
414
+
415
+
416
+ def _chmod_lock(fd: int, lock_path: Path) -> None:
417
+ fchmod = getattr(os, "fchmod", None)
418
+ try:
419
+ if fchmod is not None:
420
+ fchmod(fd, 0o600)
421
+ else: # pragma: no cover - exercised on Windows
422
+ os.chmod(lock_path, 0o600)
423
+ except OSError: # pragma: no cover - permission model is platform-specific
424
+ pass
425
+
426
+
427
+ def _atomic_json(path: Path, value: Any, *, mode: int = 0o600) -> None:
428
+ # A UNIQUE temp in the same dir (the report mkstemp recipe), not a fixed `.tmp`
429
+ # sibling: two writers to one `state.json` — apply and verify can overlap — both
430
+ # opened the same temp path and clobbered each other's content, with the loser's
431
+ # `os.replace` publishing a half-written mix.
432
+ fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
433
+ tmp = Path(tmp_name)
434
+ try:
435
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
436
+ fh.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
437
+ os.chmod(tmp, mode)
438
+ os.replace(tmp, path)
439
+ except BaseException:
440
+ tmp.unlink(missing_ok=True)
441
+ raise
442
+
443
+
444
+ def _artifact(migration_id: str) -> Path:
445
+ if not migration_id or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-" for c in migration_id):
446
+ raise MigrationError("invalid migration id")
447
+ path = _tighten_migration_root(create=False) / migration_id
448
+ if not path.is_dir():
449
+ raise MigrationError(f"migration not found: {migration_id}")
450
+ return path
451
+
452
+
453
+ def _materialized_source(migration_id: str, record: MigrationRecord) -> Path:
454
+ """Write a migrated record's content beside its migration artifacts and
455
+ return that absolute path, for use as the imported chunk's ``source_file``.
456
+
457
+ A synthetic path is not free: memtomem's orphan detection collects every
458
+ indexed ``source_file`` that no longer exists on disk and deletes its
459
+ chunks — run by the scheduled compaction job, the health watchdog, and
460
+ ``mem_cleanup_orphans``. A path that never existed is orphaned by
461
+ definition, so the migrated chunks would be swept the first time any of
462
+ those runs (and, in bulk, could instead trip the mass-delete brake and
463
+ suppress the user's real orphan cleanup).
464
+
465
+ The file lives under the retained migration artifact directory rather than
466
+ the memory dirs: it is real enough for the existence check, outside every
467
+ index root so no watcher re-chunks it under a namespace of its own, and it
468
+ is genuinely the provenance record for what was imported.
469
+ """
470
+ sources = migration_root() / migration_id / "sources"
471
+ sources.mkdir(parents=True, exist_ok=True)
472
+ # These files hold verbatim memory content; mkdir's mode is umask-masked, so
473
+ # tighten explicitly (the snapshot files beside them are written 0600 too).
474
+ os.chmod(sources, 0o700)
475
+ # The lineage key is caller-derived, so hash it: it may contain separators
476
+ # or exceed the filesystem's name limit.
477
+ name = hashlib.sha256(record.lineage_key.encode("utf-8")).hexdigest()[:32]
478
+ path = sources / f"{name}.md"
479
+ if not path.exists():
480
+ path.write_text(record.content, encoding="utf-8")
481
+ os.chmod(path, 0o600)
482
+ return path.resolve()
483
+
484
+
485
+ def _config_for(config: SyncmillConfig, backend: BackendName) -> MemoryConfig:
486
+ return config.memory.model_copy(update={"backend": backend})
487
+
488
+
489
+ class _MemtomemMaintenance:
490
+ """Migration-only adapter; sanctioned owner of memtomem maintenance internals.
491
+
492
+ Runtime access remains isolated in ``MemtomemBackend``. Migration needs
493
+ enumerate/import/delete primitives that the public backend contract does not
494
+ expose, so this adapter is the single explicit maintenance exception.
495
+ """
496
+ name: BackendName = "memtomem"
497
+
498
+ def __init__(self, config: MemoryConfig):
499
+ from syncmill.memory_backend import MemtomemBackend
500
+ self.backend = MemtomemBackend(config)
501
+
502
+ async def records(self) -> list[MigrationRecord]:
503
+ storage = await self.backend._storage()
504
+ rows: list[MigrationRecord] = []
505
+ for source in sorted(await storage.get_all_source_files()):
506
+ for chunk in await storage.list_chunks_by_source(source, limit=100_000):
507
+ rows.append(_record(
508
+ self.name, str(chunk.id), chunk.metadata.namespace, chunk.content,
509
+ list(chunk.metadata.tags), chunk.created_at.isoformat(),
510
+ ))
511
+ return rows
512
+
513
+ async def put(self, record: MigrationRecord, migration_id: str) -> str:
514
+ from memtomem.models import Chunk, ChunkMetadata, ChunkType
515
+
516
+ comp = await self.backend._store._ensure_init()
517
+ chunk_id = uuid5(NAMESPACE_URL, f"syncmill:{record.lineage_key}")
518
+ existing = await comp.storage.get_chunk(chunk_id)
519
+ if existing is not None:
520
+ return str(chunk_id)
521
+ created = (
522
+ datetime.fromisoformat(record.created_at)
523
+ if record.created_at else datetime.now(timezone.utc)
524
+ )
525
+ chunk = Chunk(
526
+ id=chunk_id,
527
+ content=record.content,
528
+ created_at=created,
529
+ metadata=ChunkMetadata(
530
+ source_file=_materialized_source(migration_id, record),
531
+ chunk_type=ChunkType.RAW_TEXT,
532
+ tags=tuple(record.tags),
533
+ namespace=record.namespace,
534
+ ),
535
+ )
536
+ chunk.embedding = (await comp.embedder.embed_texts([record.content]))[0]
537
+ await comp.storage.upsert_chunks([chunk])
538
+ return str(chunk_id)
539
+
540
+ async def get(self, target_id: str) -> MigrationRecord | None:
541
+ try:
542
+ chunk = await (await self.backend._storage()).get_chunk(UUID(target_id))
543
+ except (ValueError, TypeError):
544
+ return None
545
+ if chunk is None:
546
+ return None
547
+ return _record(
548
+ self.name, str(chunk.id), chunk.metadata.namespace, chunk.content,
549
+ list(chunk.metadata.tags), chunk.created_at.isoformat(),
550
+ )
551
+
552
+ async def delete(self, target_id: str) -> bool:
553
+ try:
554
+ return bool(await (await self.backend._storage()).delete_chunks([UUID(target_id)]))
555
+ except (ValueError, TypeError):
556
+ return False
557
+
558
+ async def close(self) -> None:
559
+ await self.backend.close()
560
+
561
+
562
+ class _Mem0Maintenance:
563
+ name: BackendName = "mem0"
564
+
565
+ def __init__(self, config: MemoryConfig):
566
+ from syncmill.memory_backend import Mem0Backend
567
+ self.backend = Mem0Backend(config)
568
+ self._lineage_ids: dict[str, str] | None = None
569
+
570
+ async def _scan(self) -> tuple[list[MigrationRecord], dict[str, str]]:
571
+ store = self.backend._qdrant_store()
572
+ offset = None
573
+ rows: list[MigrationRecord] = []
574
+ lineage_ids: dict[str, str] = {}
575
+ while True:
576
+ points, offset = await asyncio.to_thread(
577
+ store.client.scroll,
578
+ collection_name=store.collection_name,
579
+ offset=offset,
580
+ limit=100,
581
+ with_payload=True,
582
+ with_vectors=False,
583
+ )
584
+ for point in points:
585
+ payload = point.payload or {}
586
+ if "syncmill_tags" not in payload:
587
+ continue
588
+ namespace = payload.get("user_id")
589
+ content = payload.get("data")
590
+ if not isinstance(namespace, str) or not isinstance(content, str):
591
+ continue
592
+ tags = payload.get("syncmill_tags")
593
+ if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
594
+ continue
595
+ lineage = payload.get("syncmill_migration_lineage")
596
+ if isinstance(lineage, str):
597
+ lineage_ids[lineage] = str(point.id)
598
+ rows.append(_record(
599
+ self.name, str(point.id), namespace, content, list(tags),
600
+ payload.get("created_at") if isinstance(payload.get("created_at"), str) else None,
601
+ ))
602
+ if offset is None:
603
+ break
604
+ return rows, lineage_ids
605
+
606
+ async def records(self) -> list[MigrationRecord]:
607
+ rows, self._lineage_ids = await self._scan()
608
+ return rows
609
+
610
+ async def put(self, record: MigrationRecord, migration_id: str) -> str:
611
+ # One destination scan reconciles any add/state crash window for the
612
+ # whole apply. Cache it so N migrated records remain O(N), not O(N²).
613
+ if self._lineage_ids is None:
614
+ _, self._lineage_ids = await self._scan()
615
+ existing = self._lineage_ids.get(record.lineage_key)
616
+ if existing is not None:
617
+ return existing
618
+ response = await self.backend._store.add(
619
+ [{"role": "user", "content": record.content}],
620
+ user_id=record.namespace,
621
+ metadata={
622
+ "syncmill_tags": list(record.tags),
623
+ "syncmill_migration_id": migration_id,
624
+ "syncmill_migration_lineage": record.lineage_key,
625
+ "syncmill_migration_digest": record.digest,
626
+ **({"created_at": record.created_at} if record.created_at else {}),
627
+ },
628
+ infer=False,
629
+ )
630
+ results = response.get("results", []) if isinstance(response, dict) else []
631
+ if len(results) != 1 or results[0].get("memory") != record.content:
632
+ raise MigrationError("Mem0 did not confirm the migrated verbatim write")
633
+ target_id = str(results[0]["id"])
634
+ self._lineage_ids[record.lineage_key] = target_id
635
+ return target_id
636
+
637
+ async def get(self, target_id: str) -> MigrationRecord | None:
638
+ raw = await self.backend._store.get(target_id)
639
+ if not isinstance(raw, dict):
640
+ return None
641
+ namespace = raw.get("user_id")
642
+ content = raw.get("memory")
643
+ metadata = raw.get("metadata") or {}
644
+ tags = metadata.get("syncmill_tags")
645
+ if not isinstance(namespace, str) or not isinstance(content, str) or not isinstance(tags, list):
646
+ return None
647
+ return _record(self.name, target_id, namespace, content, list(tags), raw.get("created_at"))
648
+
649
+ async def delete(self, target_id: str) -> bool:
650
+ try:
651
+ await self.backend._store.delete(target_id)
652
+ return True
653
+ except Exception:
654
+ return False
655
+
656
+ async def close(self) -> None:
657
+ await self.backend.close()
658
+
659
+
660
+ class _LangGraphMaintenance:
661
+ """Migration maintenance adapter over LangGraph's persistent BaseStore."""
662
+
663
+ name: BackendName = "langgraph"
664
+
665
+ def __init__(self, config: MemoryConfig):
666
+ from syncmill.memory_backend import LangGraphBackend
667
+
668
+ self.backend = LangGraphBackend(config)
669
+
670
+ async def records(self) -> list[MigrationRecord]:
671
+ from syncmill.memory_backend import _LANGGRAPH_NS
672
+
673
+ store = await self.backend._ensure_store()
674
+ rows: list[MigrationRecord] = []
675
+ offset = 0
676
+ while True:
677
+ items = await store.asearch(
678
+ _LANGGRAPH_NS, query=None, limit=100, offset=offset
679
+ )
680
+ for item in items:
681
+ row = self.backend._row(item)
682
+ if row is None:
683
+ continue
684
+ created = getattr(item, "created_at", None)
685
+ rows.append(_record(
686
+ self.name,
687
+ row["id"],
688
+ row["namespace"],
689
+ row["content"],
690
+ list(row.get("tags", [])),
691
+ created.isoformat() if hasattr(created, "isoformat") else None,
692
+ ))
693
+ offset += len(items)
694
+ if len(items) < 100:
695
+ return rows
696
+
697
+ async def put(self, record: MigrationRecord, migration_id: str) -> str:
698
+ from syncmill.memory_backend import _LANGGRAPH_NS
699
+
700
+ target_id = str(uuid5(NAMESPACE_URL, f"syncmill:{record.lineage_key}"))
701
+ store = await self.backend._ensure_store()
702
+ existing = await store.aget(_LANGGRAPH_NS, target_id)
703
+ if existing is not None:
704
+ return target_id
705
+ value = self.backend._value(
706
+ record.content, list(record.tags), record.namespace
707
+ )
708
+ value.update({
709
+ "syncmill_migration_id": migration_id,
710
+ "syncmill_migration_lineage": record.lineage_key,
711
+ "syncmill_migration_digest": record.digest,
712
+ })
713
+ await store.aput(_LANGGRAPH_NS, target_id, value, index=["content"])
714
+ return target_id
715
+
716
+ async def get(self, target_id: str) -> MigrationRecord | None:
717
+ row = await self.backend.get(target_id)
718
+ if row is None:
719
+ return None
720
+ return _record(
721
+ self.name,
722
+ row["id"],
723
+ row["namespace"],
724
+ row["content"],
725
+ list(row.get("tags", [])),
726
+ None,
727
+ )
728
+
729
+ async def delete(self, target_id: str) -> bool:
730
+ from syncmill.memory_backend import _LANGGRAPH_NS
731
+
732
+ store = await self.backend._ensure_store()
733
+ if await store.aget(_LANGGRAPH_NS, target_id) is None:
734
+ return False
735
+ await store.adelete(_LANGGRAPH_NS, target_id)
736
+ return True
737
+
738
+ async def close(self) -> None:
739
+ await self.backend.close()
740
+
741
+
742
+ def _maintenance(config: SyncmillConfig, backend: BackendName):
743
+ cfg = _config_for(config, backend)
744
+ if backend == "memtomem":
745
+ return _MemtomemMaintenance(cfg)
746
+ if backend == "mem0":
747
+ return _Mem0Maintenance(cfg)
748
+ return _LangGraphMaintenance(cfg)
749
+
750
+
751
+ def _selected(record: MigrationRecord, namespaces: tuple[str, ...]) -> bool:
752
+ if record.namespace.startswith(_SCRATCH_PREFIX):
753
+ return False
754
+ if namespaces:
755
+ return record.namespace in namespaces
756
+ return record.namespace == "shared" or record.namespace.startswith(_PERSISTENT_PREFIX)
757
+
758
+
759
+ async def plan_migration(
760
+ config: SyncmillConfig,
761
+ source: BackendName,
762
+ target: BackendName,
763
+ namespaces: tuple[str, ...] = (),
764
+ ) -> tuple[str, dict[str, Any]]:
765
+ if source == target:
766
+ raise MigrationError("source and target backends must differ")
767
+ if any(ns.startswith(_SCRATCH_PREFIX) for ns in namespaces):
768
+ raise MigrationError("board-run:* namespaces are never migratable")
769
+ target_storage_digest = _target_storage_digest(config, target)
770
+ # Registration-as-created, like _apply_locked: `dst`'s construction can raise
771
+ # after `src` exists, and a failing `src.close()` in a plain `finally` skips
772
+ # `dst.close()` — either way an embedded store keeps a handle and its lock.
773
+ async with AsyncExitStack() as stack:
774
+ src = _maintenance(config, source)
775
+ stack.push_async_callback(src.close)
776
+ dst = _maintenance(config, target)
777
+ stack.push_async_callback(dst.close)
778
+ records = sorted(
779
+ (r for r in await src.records() if _selected(r, namespaces)),
780
+ key=lambda r: (r.namespace, r.source_id),
781
+ )
782
+ target_rows = [r for r in await dst.records() if _selected(r, namespaces)]
783
+ if target_rows:
784
+ raise MigrationError("selected destination namespaces must be empty")
785
+
786
+ migration_id = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + "-" + uuid4().hex[:8]
787
+ path = _tighten_migration_root(create=True) / migration_id
788
+ path.mkdir(mode=0o700)
789
+ os.chmod(path, 0o700)
790
+ snapshot = {"schema_version": _SCHEMA_VERSION, "records": [asdict(r) for r in records]}
791
+ manifest = {
792
+ "schema_version": _SCHEMA_VERSION,
793
+ "migration_id": migration_id,
794
+ "source": source,
795
+ "target": target,
796
+ "storage_binding_version": _STORAGE_BINDING_VERSION,
797
+ "target_storage_digest": target_storage_digest,
798
+ "namespaces": list(namespaces),
799
+ "record_count": len(records),
800
+ "source_digest": _aggregate(records),
801
+ "created_at": datetime.now(timezone.utc).isoformat(),
802
+ }
803
+ _atomic_json(path / "snapshot.json", snapshot)
804
+ _atomic_json(path / "manifest.json", manifest)
805
+ _atomic_json(path / "state.json", {"status": "planned", "targets": {}, "errors": []})
806
+ return migration_id, manifest
807
+
808
+
809
+ def _load(path: Path) -> tuple[dict[str, Any], list[MigrationRecord], dict[str, Any]]:
810
+ manifest = json.loads((path / "manifest.json").read_text(encoding="utf-8"))
811
+ snapshot = json.loads((path / "snapshot.json").read_text(encoding="utf-8"))
812
+ state = json.loads((path / "state.json").read_text(encoding="utf-8"))
813
+ manifest_version = manifest.get("schema_version")
814
+ snapshot_version = snapshot.get("schema_version")
815
+ # A v1 artifact is rejected HERE, at the boundary, rather than by a consumer:
816
+ # no path can proceed with one, and the specific recovery guidance needs only
817
+ # `state`, which this function has already read.
818
+ if manifest_version == 1:
819
+ _reject_legacy_artifact(state)
820
+ if manifest_version != _SCHEMA_VERSION:
821
+ raise MigrationError("unsupported migration artifact schema version")
822
+ if snapshot_version != manifest_version:
823
+ raise MigrationError("migration artifact schema versions do not match")
824
+ records = [MigrationRecord(**row) for row in snapshot["records"]]
825
+ if _aggregate(records) != manifest["source_digest"]:
826
+ raise MigrationError("migration snapshot digest mismatch")
827
+ return manifest, records, state
828
+
829
+
830
+ async def apply_migration(config: SyncmillConfig, migration_id: str) -> dict[str, Any]:
831
+ path = _artifact(migration_id)
832
+ with _migration_lock(path):
833
+ return await _apply_locked(config, migration_id, path)
834
+
835
+
836
+ async def _apply_locked(
837
+ config: SyncmillConfig, migration_id: str, path: Path
838
+ ) -> dict[str, Any]:
839
+ manifest, records, state = _load(path)
840
+ _validate_target_storage(config, manifest)
841
+ # AsyncExitStack, not two bare names: `dst`'s construction can raise after
842
+ # `src` exists (leaking an embedded store's handle and its lock, so the retry
843
+ # this failure invites cannot open it), and a failing `src.close()` in a plain
844
+ # `finally` skipped `dst.close()` entirely. Each adapter is registered the
845
+ # moment it exists, so every close runs.
846
+ async with AsyncExitStack() as stack:
847
+ src = _maintenance(config, manifest["source"])
848
+ stack.push_async_callback(src.close)
849
+ dst = _maintenance(config, manifest["target"])
850
+ stack.push_async_callback(dst.close)
851
+ live = sorted(
852
+ (r for r in await src.records() if _selected(r, tuple(manifest["namespaces"]))),
853
+ key=lambda r: (r.namespace, r.source_id),
854
+ )
855
+ if _aggregate(live) != manifest["source_digest"]:
856
+ raise MigrationError("source changed since migration plan")
857
+ if not state["targets"]:
858
+ existing = [r for r in await dst.records() if _selected(r, tuple(manifest["namespaces"]))]
859
+ if existing:
860
+ raise MigrationError("selected destination namespaces are no longer empty")
861
+ state["status"] = "applying"
862
+ _atomic_json(path / "state.json", state)
863
+ for record in records:
864
+ if record.lineage_key in state["targets"]:
865
+ continue
866
+ try:
867
+ state["targets"][record.lineage_key] = await dst.put(record, migration_id)
868
+ _atomic_json(path / "state.json", state)
869
+ except Exception as exc:
870
+ state["errors"].append({"lineage": record.lineage_key, "error": f"{type(exc).__name__}: {exc}"[:500]})
871
+ state["status"] = "partial"
872
+ _atomic_json(path / "state.json", state)
873
+ raise MigrationError("migration apply was partial") from exc
874
+ # Direct, NOT verify_migration: we already hold the lock (see _migration_lock).
875
+ return await _verify_locked(config, migration_id, path)
876
+
877
+
878
+ async def verify_migration(config: SyncmillConfig, migration_id: str) -> dict[str, Any]:
879
+ path = _artifact(migration_id)
880
+ with _migration_lock(path):
881
+ return await _verify_locked(config, migration_id, path)
882
+
883
+
884
+ async def _verify_locked(
885
+ config: SyncmillConfig, migration_id: str, path: Path
886
+ ) -> dict[str, Any]:
887
+ manifest, records, state = _load(path)
888
+ _validate_target_storage(config, manifest)
889
+ dst = _maintenance(config, manifest["target"])
890
+ failures: list[str] = []
891
+ by_lineage = {r.lineage_key: r for r in records}
892
+ try:
893
+ for lineage, target_id in state["targets"].items():
894
+ expected = by_lineage.get(lineage)
895
+ actual = await dst.get(target_id)
896
+ if expected is None or actual is None or actual.digest != expected.digest:
897
+ failures.append(lineage)
898
+ finally:
899
+ await dst.close()
900
+ missing_target_count = len(records) - len(state["targets"])
901
+ state["status"] = (
902
+ "verified" if not failures and missing_target_count == 0
903
+ else "verification_failed"
904
+ )
905
+ _atomic_json(path / "state.json", state)
906
+ receipt = {
907
+ "schema_version": _SCHEMA_VERSION,
908
+ "migration_id": migration_id,
909
+ "status": state["status"],
910
+ "source": manifest["source"],
911
+ "target": manifest["target"],
912
+ "record_count": len(records),
913
+ "verified_count": len(records) - len(failures) - missing_target_count,
914
+ "missing_target_count": missing_target_count,
915
+ "source_digest": manifest["source_digest"],
916
+ "failure_lineages": failures[:20],
917
+ "verified_at": datetime.now(timezone.utc).isoformat(),
918
+ }
919
+ _atomic_json(path / "receipt.json", receipt)
920
+ if failures or missing_target_count:
921
+ raise MigrationError("migration verification failed")
922
+ return receipt
923
+
924
+
925
+ async def rollback_migration(config: SyncmillConfig, migration_id: str) -> dict[str, Any]:
926
+ path = _artifact(migration_id)
927
+ with _migration_lock(path):
928
+ return await _rollback_locked(config, migration_id, path)
929
+
930
+
931
+ async def _rollback_locked(
932
+ config: SyncmillConfig, migration_id: str, path: Path
933
+ ) -> dict[str, Any]:
934
+ manifest, records, state = _load(path)
935
+ _validate_target_storage(config, manifest)
936
+ dst = _maintenance(config, manifest["target"])
937
+ by_lineage = {r.lineage_key: r for r in records}
938
+ deleted = 0
939
+ refused: list[str] = []
940
+ try:
941
+ for lineage, target_id in list(state["targets"].items()):
942
+ actual = await dst.get(target_id)
943
+ expected = by_lineage.get(lineage)
944
+ if actual is None:
945
+ state["targets"].pop(lineage, None)
946
+ continue
947
+ if expected is None or actual.digest != expected.digest:
948
+ refused.append(lineage)
949
+ continue
950
+ if await dst.delete(target_id):
951
+ deleted += 1
952
+ state["targets"].pop(lineage, None)
953
+ _atomic_json(path / "state.json", state)
954
+ finally:
955
+ await dst.close()
956
+ state["status"] = "rolled_back" if not refused and not state["targets"] else "rollback_partial"
957
+ _atomic_json(path / "state.json", state)
958
+ result = {"migration_id": migration_id, "status": state["status"], "deleted": deleted, "refused": refused[:20]}
959
+ _atomic_json(path / "rollback.json", result)
960
+ if state["status"] != "rolled_back":
961
+ raise MigrationError("migration rollback refused changed target records")
962
+ return result