aisc-cli 0.1.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 (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,440 @@
1
+ """Migration executor (Stage 7, 7d, DATA-02/03) — prepare/copy/commit/
2
+ rollback/quarantine over the 7c scan, plus the doctor payload.
3
+
4
+ Two-phase per the domain contract: copy every owned entry into a staging
5
+ tree INSIDE the data root (same volume), verify SHA-256, then atomically
6
+ ``os.replace`` into the final layout. Crash/cancel semantics:
7
+
8
+ - the manifest (``<root>/migrations/<ws-dir-name>.json``, state=prepared)
9
+ is the migration boundary — it is persisted BEFORE copying and updated
10
+ as entries complete, so ``migrate`` re-run = resume (already-copied
11
+ entries verified and skipped), never a partial overwrite;
12
+ - sources are never modified, moved or deleted (rollback only removes
13
+ target writes this manifest made; unknown files move to quarantine only
14
+ with explicit consent); a fully-migrated namespace gets a read-only
15
+ ``.aisc-migrated`` redirect marker (old entries find it via doctor);
16
+ - conflicts (target exists with different bytes) and changed sources fail
17
+ closed with stable ``AISC_ERR_DATA_MIGRATION_*`` codes — non-interactive
18
+ callers exit non-zero, never guess.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import shutil
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Callable, Dict, List, Optional
29
+
30
+ from aisc.adapters.data_root_store import SCOPE_SHARED, SCOPE_WORKSPACE, DataRootStore
31
+ from aisc.application.legacy_scan import LegacyScan, scan_legacy_workspace
32
+ from aisc.domain.data_migration import (
33
+ ENTRY_CONFLICT,
34
+ ENTRY_OWNED,
35
+ ENTRY_TRANSIENT,
36
+ ENTRY_UNKNOWN,
37
+ ERR_CONFLICT,
38
+ ERR_CORRUPT_COPY,
39
+ ERR_INSUFFICIENT_SPACE,
40
+ ERR_SOURCE_CHANGED,
41
+ ERR_UNKNOWN_PENDING,
42
+ MARKER_NAME,
43
+ MIGRATION_PROTOCOL,
44
+ MIGRATION_SCHEMA_VERSION,
45
+ STATE_COMMITTED,
46
+ STATE_PREPARED,
47
+ STATE_ROLLED_BACK,
48
+ STATUS_COPIED,
49
+ STATUS_PENDING,
50
+ STATUS_QUARANTINED,
51
+ MigrationEntry,
52
+ MigrationManifest,
53
+ )
54
+ from aisc.domain.data_root import ResolvedDataRoot, workspace_dir_name
55
+ from aisc.domain.models import CliError
56
+
57
+ MIGRATION_LOCK = "migration"
58
+ _SPACE_MARGIN_BYTES = 64 * 1024 * 1024
59
+ _SPACE_FACTOR = 1.1
60
+
61
+ ProgressFn = Callable[[int, int, str], None]
62
+ ContinueFn = Callable[[], bool]
63
+
64
+
65
+ @dataclass
66
+ class MigrationResult:
67
+ """Machine-readable outcome for the CLI envelope / Workbench."""
68
+
69
+ outcome: str # "committed" | "cancelled" | "rolled_back"
70
+ manifest: str # manifest path
71
+ copied: int = 0
72
+ skipped: int = 0 # already in place (resume / re-migrate)
73
+ quarantined: int = 0
74
+ removed: int = 0 # rollback: target files removed
75
+ kept: int = 0 # rollback: user-modified targets KEPT
76
+ restored: int = 0 # rollback: quarantined files restored
77
+ markers: List[str] = field(default_factory=list)
78
+
79
+ def to_dict(self) -> Dict[str, object]:
80
+ return {
81
+ "outcome": self.outcome,
82
+ "manifest": self.manifest,
83
+ "copied": self.copied,
84
+ "skipped": self.skipped,
85
+ "quarantined": self.quarantined,
86
+ "removed": self.removed,
87
+ "kept": self.kept,
88
+ "restored": self.restored,
89
+ "markers": list(self.markers),
90
+ }
91
+
92
+
93
+ def _sha256_or_none(path: Path) -> Optional[str]:
94
+ import hashlib
95
+
96
+ try:
97
+ digest = hashlib.sha256()
98
+ with open(path, "rb") as f:
99
+ while True:
100
+ chunk = f.read(1 << 16)
101
+ if not chunk:
102
+ break
103
+ digest.update(chunk)
104
+ return digest.hexdigest()
105
+ except OSError:
106
+ return None
107
+
108
+
109
+ def _copy_file(src: Path, dst: Path) -> None:
110
+ dst.parent.mkdir(parents=True, exist_ok=True)
111
+ tmp = dst.with_name(dst.name + ".part")
112
+ shutil.copyfile(src, tmp)
113
+ # "r+b": fsync (FlushFileBuffers) needs a write handle on Windows.
114
+ with open(tmp, "r+b") as f:
115
+ os.fsync(f.fileno())
116
+ os.replace(tmp, dst)
117
+
118
+
119
+ class MigrationExecutor:
120
+ """Executes one workspace's legacy→data-root migration."""
121
+
122
+ def __init__(self, workspace: Path, resolved: ResolvedDataRoot,
123
+ store: Optional[DataRootStore] = None) -> None:
124
+ self.workspace = Path(workspace)
125
+ self.resolved = resolved
126
+ self.store = store or DataRootStore(resolved)
127
+ self._ws_dir_name = workspace_dir_name(resolved.workspace_hash)
128
+
129
+ # -- locations -----------------------------------------------------------
130
+
131
+ def manifest_path(self) -> Path:
132
+ return self.resolved.root / "migrations" / f"{self._ws_dir_name}.json"
133
+
134
+ def staging_dir(self) -> Path:
135
+ return self.resolved.root / "migrations" / "staging" / self._ws_dir_name
136
+
137
+ def quarantine_dir(self) -> Path:
138
+ return self.resolved.root / "migrations" / "quarantine" / self._ws_dir_name
139
+
140
+ # -- read-only surfaces (doctor / dry-run) --------------------------------
141
+
142
+ def doctor(self) -> Dict[str, object]:
143
+ """Resolver facts + legacy findings + pending-manifest state."""
144
+ scan = scan_legacy_workspace(self.workspace, self.resolved)
145
+ pending = None
146
+ if self.manifest_path().is_file():
147
+ try:
148
+ manifest = MigrationManifest.from_dict(
149
+ json.loads(self.manifest_path().read_text(encoding="utf-8"))
150
+ )
151
+ pending = {"state": manifest.state, "entries": len(manifest.entries)}
152
+ except (ValueError, json.JSONDecodeError, OSError):
153
+ pending = {"state": "unreadable"}
154
+ return {
155
+ "data_root": self.resolved.to_dict(),
156
+ "legacy": scan.summary(),
157
+ "pending_manifest": pending,
158
+ }
159
+
160
+ def dry_run(self) -> Dict[str, object]:
161
+ """Planned actions without touching anything."""
162
+ scan = scan_legacy_workspace(self.workspace, self.resolved)
163
+ return {
164
+ "schema": MIGRATION_PROTOCOL,
165
+ "workspace_hash": self.resolved.workspace_hash,
166
+ "target": str(self.resolved.workspace_dir),
167
+ "summary": scan.summary(),
168
+ "conflicts": [e.relative for e in scan.entries
169
+ if e.classification == ENTRY_CONFLICT],
170
+ "unknowns": [e.relative for e in scan.entries
171
+ if e.classification == ENTRY_UNKNOWN],
172
+ "plan": {
173
+ "copy_count": scan.counts()[ENTRY_OWNED],
174
+ "skip_count": scan.counts()[ENTRY_TRANSIENT],
175
+ "owned_bytes": scan.owned_bytes(),
176
+ },
177
+ }
178
+
179
+ # -- execution -------------------------------------------------------------
180
+
181
+ def migrate(
182
+ self,
183
+ *,
184
+ quarantine_unknown: bool = False,
185
+ progress: Optional[ProgressFn] = None,
186
+ should_continue: Optional[ContinueFn] = None,
187
+ ) -> MigrationResult:
188
+ scan = scan_legacy_workspace(self.workspace, self.resolved)
189
+ self._gate(scan, quarantine_unknown)
190
+
191
+ with self.store.lock(MIGRATION_LOCK, scope=SCOPE_WORKSPACE):
192
+ manifest = self._prepare(scan)
193
+ result = MigrationResult(outcome="cancelled",
194
+ manifest=str(self.manifest_path()))
195
+ staged_any = False
196
+ try:
197
+ owned = [e for e in manifest.entries if e.classification == ENTRY_OWNED]
198
+ total = len(owned)
199
+ done = 0
200
+ for entry in owned:
201
+ if should_continue is not None and not should_continue():
202
+ # Cancel: keep manifest (state=prepared), sources
203
+ # untouched; a later run resumes.
204
+ manifest = self._persist(manifest)
205
+ return result
206
+ done += 1
207
+ if progress is not None:
208
+ progress(done, total, entry.relative)
209
+ self._stage_entry(entry, manifest)
210
+ staged_any = True
211
+ # Persist progress every entry: crash-safe resume.
212
+ manifest = self._persist(manifest)
213
+ self._commit(manifest, result)
214
+ if quarantine_unknown:
215
+ self._quarantine(manifest, result)
216
+ self._write_markers(manifest, result)
217
+ manifest.state = STATE_COMMITTED
218
+ self._persist(manifest)
219
+ result.outcome = "committed"
220
+ return result
221
+ finally:
222
+ if staged_any:
223
+ shutil.rmtree(self.staging_dir(), ignore_errors=True)
224
+
225
+ def rollback(self, manifest_path: Optional[Path] = None) -> MigrationResult:
226
+ path = Path(manifest_path) if manifest_path else self.manifest_path()
227
+ if not path.is_file():
228
+ raise CliError(f"migration manifest not found: {path}",
229
+ exit_code=1, error_code=ERR_SOURCE_CHANGED)
230
+ try:
231
+ manifest = MigrationManifest.from_dict(
232
+ json.loads(path.read_text(encoding="utf-8"))
233
+ )
234
+ except (ValueError, json.JSONDecodeError) as exc:
235
+ raise CliError(f"unreadable migration manifest: {path} ({exc})",
236
+ exit_code=1, error_code=ERR_CORRUPT_COPY) from exc
237
+
238
+ result = MigrationResult(outcome="rolled_back", manifest=str(path))
239
+ with self.store.lock(MIGRATION_LOCK, scope=SCOPE_WORKSPACE):
240
+ for entry in manifest.entries:
241
+ if entry.status == STATUS_COPIED and entry.target:
242
+ target = self.resolved.workspace_dir / Path(*entry.target.split("/"))
243
+ current = _sha256_or_none(target)
244
+ if current is None:
245
+ continue
246
+ if current == entry.sha256:
247
+ target.unlink()
248
+ result.removed += 1
249
+ else:
250
+ # User-modified since migration: NEVER delete (fail
251
+ # closed per entry, continue with the rest).
252
+ result.kept += 1
253
+ elif entry.status == STATUS_QUARANTINED:
254
+ self._restore_quarantined(entry, result)
255
+ for ns in manifest.markers:
256
+ marker = self.workspace / ns / MARKER_NAME
257
+ try:
258
+ marker.unlink()
259
+ except OSError:
260
+ pass
261
+ manifest.state = STATE_ROLLED_BACK
262
+ self._persist(manifest)
263
+ return result
264
+
265
+ # -- internals ---------------------------------------------------------------
266
+
267
+ def _gate(self, scan: LegacyScan, quarantine_unknown: bool) -> None:
268
+ conflicts = [e.relative for e in scan.entries
269
+ if e.classification == ENTRY_CONFLICT]
270
+ if conflicts:
271
+ raise CliError(
272
+ f"migration blocked by {len(conflicts)} conflict(s), e.g. {conflicts[0]!r}",
273
+ exit_code=1, error_code=ERR_CONFLICT,
274
+ hint="Resolve target conflicts (rollback or inspect) before migrating.")
275
+ unknowns = [e.relative for e in scan.entries
276
+ if e.classification == ENTRY_UNKNOWN]
277
+ if unknowns and not quarantine_unknown:
278
+ raise CliError(
279
+ f"{len(unknowns)} unknown file(s) inside AISC namespaces, "
280
+ f"e.g. {unknowns[0]!r}",
281
+ exit_code=1, error_code=ERR_UNKNOWN_PENDING,
282
+ hint="Pass --quarantine-unknown to move them to the migration "
283
+ "quarantine (sources are kept until you confirm).")
284
+
285
+ def _prepare(self, scan: LegacyScan) -> MigrationManifest:
286
+ manifest = scan.to_manifest()
287
+ self._check_space(manifest)
288
+ self.store.prepare()
289
+ self.staging_dir().mkdir(parents=True, exist_ok=True)
290
+ # Resume: an earlier prepared manifest is the boundary — reuse the
291
+ # statuses of entries that already completed.
292
+ if self.manifest_path().is_file():
293
+ try:
294
+ prior = MigrationManifest.from_dict(
295
+ json.loads(self.manifest_path().read_text(encoding="utf-8"))
296
+ )
297
+ except (ValueError, json.JSONDecodeError, OSError):
298
+ prior = None
299
+ if prior is not None and prior.state == STATE_PREPARED:
300
+ done = {(e.relative, e.sha256): e.status for e in prior.entries}
301
+ for entry in manifest.entries:
302
+ entry.status = done.get((entry.relative, entry.sha256),
303
+ STATUS_PENDING)
304
+ manifest.state = STATE_PREPARED
305
+ self._persist(manifest)
306
+ return manifest
307
+
308
+ def _check_space(self, manifest: MigrationManifest) -> None:
309
+ need = int(sum(e.size for e in manifest.entries
310
+ if e.classification == ENTRY_OWNED) * _SPACE_FACTOR)
311
+ need += _SPACE_MARGIN_BYTES
312
+ usage = shutil.disk_usage(str(self.resolved.root))
313
+ if usage.free < need:
314
+ raise CliError(
315
+ f"insufficient space: need ~{need} bytes, have {usage.free}",
316
+ exit_code=1, error_code=ERR_INSUFFICIENT_SPACE,
317
+ hint="Free space under the data root drive and retry.")
318
+
319
+ def _stage_entry(self, entry: MigrationEntry, manifest: MigrationManifest) -> None:
320
+ if entry.status == STATUS_COPIED:
321
+ return
322
+ src = self.workspace / Path(*entry.relative.split("/"))
323
+ if not src.is_file():
324
+ raise CliError(f"source vanished during migration: {entry.relative}",
325
+ exit_code=1, error_code=ERR_SOURCE_CHANGED)
326
+ current = _sha256_or_none(src) or ""
327
+ if current != entry.sha256:
328
+ raise CliError(
329
+ f"source changed during migration: {entry.relative}",
330
+ exit_code=1, error_code=ERR_SOURCE_CHANGED,
331
+ hint="Re-run the migration to re-scan the changed files.")
332
+
333
+ final = self.resolved.workspace_dir / Path(*entry.target.split("/"))
334
+ final_hash = _sha256_or_none(final)
335
+ if final_hash == entry.sha256:
336
+ entry.status = STATUS_COPIED # already migrated (resume/re-run)
337
+ return
338
+ if final.exists():
339
+ raise CliError(
340
+ f"target exists with different content: {entry.target}",
341
+ exit_code=1, error_code=ERR_CONFLICT)
342
+
343
+ staged = self.staging_dir() / Path(*entry.target.split("/"))
344
+ staged_hash = _sha256_or_none(staged)
345
+ if staged_hash != entry.sha256:
346
+ _copy_file(src, staged)
347
+ staged_hash = _sha256_or_none(staged)
348
+ if staged_hash != entry.sha256:
349
+ raise CliError(f"copy verification failed: {entry.relative}",
350
+ exit_code=1, error_code=ERR_CORRUPT_COPY)
351
+
352
+ def _commit(self, manifest: MigrationManifest, result: MigrationResult) -> None:
353
+ for entry in manifest.entries:
354
+ if entry.classification != ENTRY_OWNED:
355
+ continue
356
+ if entry.status == STATUS_COPIED:
357
+ result.skipped += 1
358
+ continue
359
+ staged = self.staging_dir() / Path(*entry.target.split("/"))
360
+ final = self.resolved.workspace_dir / Path(*entry.target.split("/"))
361
+ final.parent.mkdir(parents=True, exist_ok=True)
362
+ os.replace(staged, final) # same volume: atomic
363
+ if _sha256_or_none(final) != entry.sha256:
364
+ raise CliError(f"commit verification failed: {entry.target}",
365
+ exit_code=1, error_code=ERR_CORRUPT_COPY)
366
+ entry.status = STATUS_COPIED
367
+ result.copied += 1
368
+
369
+ def _quarantine(self, manifest: MigrationManifest, result: MigrationResult) -> None:
370
+ for entry in manifest.entries:
371
+ if entry.classification != ENTRY_UNKNOWN:
372
+ continue
373
+ if not entry.sha256:
374
+ # Reparse/symlink entries are never hashed — never follow.
375
+ continue
376
+ src = self.workspace / Path(*entry.relative.split("/"))
377
+ if not src.is_file() or src.is_symlink():
378
+ continue
379
+ dst = self.quarantine_dir() / Path(*entry.relative.split("/"))
380
+ # Copy → verify → remove source: byte-safe move even across
381
+ # volumes; the manifest records the original path for rollback.
382
+ _copy_file(src, dst)
383
+ if _sha256_or_none(dst) == entry.sha256:
384
+ src.unlink()
385
+ entry.status = STATUS_QUARANTINED
386
+ result.quarantined += 1
387
+
388
+ def _restore_quarantined(self, entry: MigrationEntry,
389
+ result: MigrationResult) -> None:
390
+ qpath = self.quarantine_dir() / Path(*entry.relative.split("/"))
391
+ dest = self.workspace / Path(*entry.relative.split("/"))
392
+ if not qpath.is_file():
393
+ return
394
+ if dest.exists():
395
+ result.kept += 1 # never overwrite anything present at the source
396
+ return
397
+ dest.parent.mkdir(parents=True, exist_ok=True)
398
+ _copy_file(qpath, dest)
399
+ if _sha256_or_none(dest) == (entry.sha256 or _sha256_or_none(qpath)):
400
+ qpath.unlink()
401
+ result.restored += 1
402
+
403
+ def _write_markers(self, manifest: MigrationManifest,
404
+ result: MigrationResult) -> None:
405
+ """A namespace earns a redirect marker only when every owned file of
406
+ it migrated AND nothing unknown is left there (unknowns either moved
407
+ to quarantine or none existed)."""
408
+ pending_unknown = {e.relative.split("/", 1)[0] for e in manifest.entries
409
+ if e.classification == ENTRY_UNKNOWN
410
+ and e.status != STATUS_QUARANTINED}
411
+ for ns in (".aisc", ".claude", ".codex", ".cc-switch"):
412
+ ns_dir = self.workspace / ns
413
+ if not ns_dir.is_dir() or ns in pending_unknown:
414
+ continue
415
+ owned_ns = [e for e in manifest.entries
416
+ if e.classification == ENTRY_OWNED
417
+ and e.relative.startswith(ns + "/")]
418
+ if not owned_ns or not all(e.status == STATUS_COPIED for e in owned_ns):
419
+ continue
420
+ marker = ns_dir / MARKER_NAME
421
+ marker.write_text(json.dumps({
422
+ "schema": MIGRATION_PROTOCOL,
423
+ "schema_version": MIGRATION_SCHEMA_VERSION,
424
+ "workspace_hash": manifest.workspace_hash,
425
+ "data_root": str(self.resolved.root),
426
+ "manifest": self.manifest_path().name,
427
+ }, indent=2) + "\n", encoding="utf-8")
428
+ if ns not in manifest.markers:
429
+ manifest.markers.append(ns)
430
+ if ns not in result.markers:
431
+ result.markers.append(ns)
432
+
433
+ def _persist(self, manifest: MigrationManifest) -> MigrationManifest:
434
+ # Caller already holds the migration lock (migrate/rollback wrap the
435
+ # whole cycle) — write WITHOUT re-locking (same-process byte locks
436
+ # on a second handle would self-deadlock).
437
+ self.store.write_json(
438
+ SCOPE_SHARED, f"migrations/{self._ws_dir_name}.json", manifest.to_dict()
439
+ )
440
+ return manifest