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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|