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,303 @@
1
+ """cc-switch release selection — pure domain logic (Stage 8, CS-01/CS-02).
2
+
3
+ Operates on GitHub Releases API dicts (already fetched; this module never
4
+ performs I/O). Encodes the asset contract proven by the 8a black-box probe
5
+ (``docs/plans/aisc-next-followup/stage-8-cc-switch-provider-ui/8a-discovery-report.md``):
6
+
7
+ - asset names: ``cc-switch-cli-{tag}-linux-{x64|arm64}-{musl|glibc}.tar.gz``
8
+ (versioned, preferred) and the unversioned twin inside the SAME release;
9
+ the arch token is ``x64`` — NOT ``amd64``;
10
+ - every release asset carries an authoritative ``digest`` (``sha256:<hex>``)
11
+ from the Releases API — a missing digest fails closed;
12
+ - only releases with ``prerelease=false, draft=false`` and a semver ``vX.Y.Z``
13
+ tag are eligible for the ``stable`` channel.
14
+
15
+ Fail-closed everywhere: an ambiguous or unverifiable release is rejected,
16
+ never guessed.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ RESOLVED_SCHEMA = "aisc.cc-switch-resolved/v1"
26
+ UPSTREAM_REPO = "saladday/cc-switch-cli"
27
+
28
+ # Stable error codes (never change the string; diagnostics rely on them).
29
+ CC_SWITCH_ERROR_INVALID_CHANNEL = "AISC_ERR_CC_SWITCH_INVALID_CHANNEL"
30
+ CC_SWITCH_ERROR_INVALID_TAG = "AISC_ERR_CC_SWITCH_INVALID_TAG"
31
+ CC_SWITCH_ERROR_NO_STABLE_RELEASE = "AISC_ERR_CC_SWITCH_NO_STABLE_RELEASE"
32
+ CC_SWITCH_ERROR_VERSION_NOT_FOUND = "AISC_ERR_CC_SWITCH_VERSION_NOT_FOUND"
33
+ CC_SWITCH_ERROR_NO_MATCHING_ASSET = "AISC_ERR_CC_SWITCH_NO_MATCHING_ASSET"
34
+ CC_SWITCH_ERROR_MISSING_DIGEST = "AISC_ERR_CC_SWITCH_MISSING_DIGEST"
35
+ CC_SWITCH_ERROR_BAD_MANIFEST = "AISC_ERR_CC_SWITCH_BAD_MANIFEST"
36
+ CC_SWITCH_ERROR_MANIFEST_MISMATCH = "AISC_ERR_CC_SWITCH_MANIFEST_MISMATCH"
37
+ CC_SWITCH_ERROR_NETWORK = "AISC_ERR_CC_SWITCH_NETWORK"
38
+ CC_SWITCH_ERROR_RATE_LIMITED = "AISC_ERR_CC_SWITCH_RATE_LIMITED"
39
+
40
+ SEMVER_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
41
+ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
42
+
43
+ # Arch tokens upstream uses in asset names (map your platform's uname arch
44
+ # through this before calling select_release).
45
+ ASSET_ARCHS = ("x64", "arm64")
46
+ ASSET_LIBCS = ("musl", "glibc")
47
+
48
+ # Where a resolution came from — surfaced in the manifest so a cached or
49
+ # file-supplied resolution is never mistaken for a live one (fail-visible).
50
+ SOURCE_API = "api"
51
+ SOURCE_CACHE = "cache"
52
+ SOURCE_MANIFEST = "manifest"
53
+
54
+
55
+ class ResolveError(Exception):
56
+ """Release selection failure with a stable code (fail closed)."""
57
+
58
+ def __init__(self, code: str, message: str):
59
+ super().__init__(message)
60
+ self.code = code
61
+ self.message = message
62
+
63
+
64
+ def normalize_arch(platform_arch: str) -> str:
65
+ """Map a platform arch (``amd64``/``x86_64``/``arm64``/``aarch64``) to the
66
+ upstream asset token (``x64``/``arm64``). Unknown arches fail closed."""
67
+ a = platform_arch.strip().lower()
68
+ if a in ("amd64", "x86_64", "x64"):
69
+ return "x64"
70
+ if a in ("arm64", "aarch64"):
71
+ return "arm64"
72
+ raise ResolveError(
73
+ CC_SWITCH_ERROR_NO_MATCHING_ASSET,
74
+ f"unsupported platform arch for cc-switch assets: {platform_arch!r}",
75
+ )
76
+
77
+
78
+ def _asset_names(tag: str, arch: str, libc: str) -> Tuple[str, str]:
79
+ """(versioned, unversioned) asset names for a release tag."""
80
+ return (
81
+ f"cc-switch-cli-{tag}-linux-{arch}-{libc}.tar.gz",
82
+ f"cc-switch-cli-linux-{arch}-{libc}.tar.gz",
83
+ )
84
+
85
+
86
+ def _usable_asset(release: Dict[str, Any], tag: str, arch: str, libc: str) -> Optional[Dict[str, Any]]:
87
+ """Pick the asset for this release matching the arch/libc contract, with a
88
+ trustworthy sha256 digest. Versioned name wins; the unversioned twin in
89
+ the same release is the same file. Missing/invalid digest -> not usable."""
90
+ versioned, unversioned = _asset_names(tag, arch, libc)
91
+ assets = {a.get("name", ""): a for a in release.get("assets", [])}
92
+ chosen = assets.get(versioned) or assets.get(unversioned)
93
+ if chosen is None:
94
+ return None
95
+ digest = str(chosen.get("digest") or "")
96
+ sha = digest.split(":", 1)[1] if digest.startswith("sha256:") else ""
97
+ if not _SHA256_RE.match(sha):
98
+ return None
99
+ return chosen
100
+
101
+
102
+ def _eligible(release: Dict[str, Any]) -> bool:
103
+ if release.get("draft") or release.get("prerelease"):
104
+ return False
105
+ return bool(SEMVER_TAG_RE.match(str(release.get("tag_name") or "")))
106
+
107
+
108
+ def select_release(
109
+ releases: List[Dict[str, Any]],
110
+ *,
111
+ channel: str = "stable",
112
+ version: str = "latest",
113
+ arch: str = "x64",
114
+ libc: str = "musl",
115
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
116
+ """Pure selection: (release, asset) for the channel/version/arch/libc.
117
+
118
+ ``version="latest"`` takes the newest eligible release that HAS a usable
119
+ asset (skipping empty releases); an explicit ``vX.Y.Z`` must exist and
120
+ have the asset (fail closed otherwise).
121
+ """
122
+ if channel != "stable":
123
+ raise ResolveError(
124
+ CC_SWITCH_ERROR_INVALID_CHANNEL,
125
+ f"unsupported cc-switch channel {channel!r} (only 'stable')",
126
+ )
127
+ if arch not in ASSET_ARCHS or libc not in ASSET_LIBCS:
128
+ raise ResolveError(
129
+ CC_SWITCH_ERROR_NO_MATCHING_ASSET,
130
+ f"unsupported asset target linux-{arch}-{libc}",
131
+ )
132
+
133
+ want_explicit = version != "latest"
134
+ if want_explicit and not SEMVER_TAG_RE.match(version):
135
+ raise ResolveError(
136
+ CC_SWITCH_ERROR_INVALID_TAG,
137
+ f"cc-switch version must be 'latest' or vX.Y.Z, got {version!r}",
138
+ )
139
+
140
+ ordered = sorted(
141
+ (r for r in releases if _eligible(r)),
142
+ key=lambda r: str(r.get("published_at") or r.get("created_at") or ""),
143
+ reverse=True,
144
+ )
145
+
146
+ if want_explicit:
147
+ candidates = [r for r in ordered if r.get("tag_name") == version]
148
+ if not candidates:
149
+ raise ResolveError(
150
+ CC_SWITCH_ERROR_VERSION_NOT_FOUND,
151
+ f"cc-switch {version} not found in the stable channel",
152
+ )
153
+ else:
154
+ candidates = ordered
155
+
156
+ for release in candidates:
157
+ asset = _usable_asset(release, str(release["tag_name"]), arch, libc)
158
+ if asset is not None:
159
+ return release, asset
160
+
161
+ if want_explicit:
162
+ raise ResolveError(
163
+ CC_SWITCH_ERROR_NO_MATCHING_ASSET,
164
+ f"cc-switch {version} has no linux-{arch}-{libc}.tar.gz asset with a sha256 digest",
165
+ )
166
+ raise ResolveError(
167
+ CC_SWITCH_ERROR_NO_STABLE_RELEASE,
168
+ f"no stable cc-switch release with a usable linux-{arch}-{libc} asset",
169
+ )
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class ResolvedRelease:
174
+ """The frozen resolution result consumed by the build and baked into the
175
+ image labels / manifest (02-domain-contract.md §Build input)."""
176
+
177
+ channel: str
178
+ tag: str
179
+ release_id: int
180
+ commit: str
181
+ published_at: str
182
+ asset_name: str
183
+ asset_url: str
184
+ asset_sha256: str
185
+ asset_size: int
186
+ arch: str
187
+ libc: str
188
+ source: str # api | cache | manifest
189
+
190
+ def to_manifest(self, *, resolved_at: str = "") -> Dict[str, Any]:
191
+ return {
192
+ "schema": RESOLVED_SCHEMA,
193
+ "channel": self.channel,
194
+ "version": self.tag,
195
+ "release_id": self.release_id,
196
+ "commit": self.commit,
197
+ "published_at": self.published_at,
198
+ "asset_name": self.asset_name,
199
+ "asset_url": self.asset_url,
200
+ "asset_sha256": self.asset_sha256,
201
+ "asset_size": self.asset_size,
202
+ "arch": self.arch,
203
+ "libc": self.libc,
204
+ "source": self.source,
205
+ "resolved_at": resolved_at,
206
+ }
207
+
208
+ @classmethod
209
+ def from_manifest(cls, data: Dict[str, Any]) -> "ResolvedRelease":
210
+ """Strict load for ``--cc-switch-manifest`` (offline/reproducible
211
+ builds). Unknown extra keys are ignored; missing/invalid fields fail
212
+ closed — a partial manifest must never enter a build."""
213
+ try:
214
+ schema = data["schema"]
215
+ obj = cls(
216
+ channel=str(data["channel"]),
217
+ tag=str(data["version"]),
218
+ release_id=int(data["release_id"]),
219
+ commit=str(data["commit"]),
220
+ published_at=str(data["published_at"]),
221
+ asset_name=str(data["asset_name"]),
222
+ asset_url=str(data["asset_url"]),
223
+ asset_sha256=str(data["asset_sha256"]),
224
+ asset_size=int(data["asset_size"]),
225
+ arch=str(data["arch"]),
226
+ libc=str(data["libc"]),
227
+ source=str(data.get("source") or SOURCE_MANIFEST),
228
+ )
229
+ except (KeyError, TypeError, ValueError) as exc:
230
+ raise ResolveError(
231
+ CC_SWITCH_ERROR_BAD_MANIFEST,
232
+ f"cc-switch manifest is missing/invalid fields: {exc}",
233
+ ) from exc
234
+ if schema != RESOLVED_SCHEMA:
235
+ raise ResolveError(
236
+ CC_SWITCH_ERROR_BAD_MANIFEST,
237
+ f"cc-switch manifest schema {schema!r} != {RESOLVED_SCHEMA!r}",
238
+ )
239
+ if not SEMVER_TAG_RE.match(obj.tag):
240
+ raise ResolveError(
241
+ CC_SWITCH_ERROR_BAD_MANIFEST,
242
+ f"cc-switch manifest version {obj.tag!r} is not vX.Y.Z",
243
+ )
244
+ if not _SHA256_RE.match(obj.asset_sha256):
245
+ raise ResolveError(
246
+ CC_SWITCH_ERROR_BAD_MANIFEST,
247
+ "cc-switch manifest asset_sha256 is not a bare sha256 hex digest",
248
+ )
249
+ if obj.asset_name != _asset_names(obj.tag, obj.arch, obj.libc)[0] and \
250
+ obj.asset_name != _asset_names(obj.tag, obj.arch, obj.libc)[1]:
251
+ raise ResolveError(
252
+ CC_SWITCH_ERROR_BAD_MANIFEST,
253
+ f"cc-switch manifest asset_name {obj.asset_name!r} does not match "
254
+ f"linux-{obj.arch}-{obj.libc} for {obj.tag}",
255
+ )
256
+ if obj.source not in (SOURCE_API, SOURCE_CACHE, SOURCE_MANIFEST):
257
+ raise ResolveError(
258
+ CC_SWITCH_ERROR_BAD_MANIFEST,
259
+ f"cc-switch manifest source {obj.source!r} is unknown",
260
+ )
261
+ return obj
262
+
263
+ def check_matches_request(self, channel: str, version: str) -> None:
264
+ """Cross-check a loaded manifest against the requested channel/version
265
+ (both explicit) — a mismatch fails closed instead of building the
266
+ wrong thing."""
267
+ if channel != self.channel:
268
+ raise ResolveError(
269
+ CC_SWITCH_ERROR_MANIFEST_MISMATCH,
270
+ f"manifest channel {self.channel!r} != requested {channel!r}",
271
+ )
272
+ if version != "latest" and version != self.tag:
273
+ raise ResolveError(
274
+ CC_SWITCH_ERROR_MANIFEST_MISMATCH,
275
+ f"manifest version {self.tag!r} != requested {version!r}",
276
+ )
277
+
278
+
279
+ def resolved_from_release(
280
+ release: Dict[str, Any],
281
+ asset: Dict[str, Any],
282
+ *,
283
+ channel: str,
284
+ arch: str,
285
+ libc: str,
286
+ source: str,
287
+ ) -> ResolvedRelease:
288
+ """Build a ResolvedRelease from an API release+asset pair."""
289
+ tag = str(release["tag_name"])
290
+ return ResolvedRelease(
291
+ channel=channel,
292
+ tag=tag,
293
+ release_id=int(release.get("id") or 0),
294
+ commit=str(release.get("target_commitish") or ""),
295
+ published_at=str(release.get("published_at") or ""),
296
+ asset_name=str(asset["name"]),
297
+ asset_url=str(asset.get("browser_download_url") or ""),
298
+ asset_sha256=str(asset.get("digest") or "").split(":", 1)[-1],
299
+ asset_size=int(asset.get("size") or 0),
300
+ arch=arch,
301
+ libc=libc,
302
+ source=source,
303
+ )
aisc/domain/config.py ADDED
@@ -0,0 +1,64 @@
1
+ """Pure domain models for AISC profile and network configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class PlatformPathConfig:
13
+ """Platform-specific AISC config root."""
14
+
15
+ config_dir: str
16
+ state_dir: str
17
+
18
+
19
+ def _validate_absolute_root(name: str, value: Optional[str]) -> Optional[str]:
20
+ if value is None:
21
+ return None
22
+ if value.strip() != value:
23
+ raise ValueError(f"{name} has leading/trailing whitespace")
24
+ if not value:
25
+ raise ValueError(f"{name} must not be empty")
26
+ if not os.path.isabs(value):
27
+ raise ValueError(f"{name} must be an absolute path")
28
+ return value
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PathPolicy:
33
+ """Validated roots used while reading AISC configuration."""
34
+
35
+ platform: PlatformPathConfig
36
+ workspace: Optional[str] = None
37
+ aisc_root: Optional[str] = None
38
+
39
+ def __post_init__(self) -> None:
40
+ object.__setattr__(
41
+ self, "workspace", _validate_absolute_root("workspace", self.workspace)
42
+ )
43
+ object.__setattr__(
44
+ self, "aisc_root", _validate_absolute_root("aisc_root", self.aisc_root)
45
+ )
46
+
47
+
48
+ class IssueSeverity(str, Enum):
49
+ ERROR = "error"
50
+ WARNING = "warning"
51
+
52
+
53
+ @dataclass
54
+ class SchemaIssue:
55
+ severity: IssueSeverity
56
+ path: str = ""
57
+ reason_code: str = ""
58
+ message: str = ""
59
+
60
+ def __repr__(self) -> str:
61
+ return (
62
+ f"{self.severity.value.upper()}: {self.path} "
63
+ f"[{self.reason_code}] — {self.message}"
64
+ )
@@ -0,0 +1,259 @@
1
+ """Legacy-layout migration contract (Stage 7, 7c, DATA-02/D7-03).
2
+
3
+ Pure tables and schema for migrating the pre-Stage-7 workspace layout (see
4
+ the measured fresh-init inventory in stage-7/02-domain-contract.md) into the
5
+ data root. The walker lives in ``application/legacy_scan.py``; execution
6
+ (prepare/commit/rollback/quarantine) is 7d.
7
+
8
+ Classification (D7-03 — protect user files, migrate only the known):
9
+ - ``owned`` allowlisted AISC/agent state → migratable, hashed, mapped;
10
+ - ``transient`` locks, pid files, live-SQLite sidecars' init locks, daemon
11
+ logs — coordination/bounded artifacts, left in place, never
12
+ migrated (cc-switch.db itself IS owned; -shm/-wal migrate as
13
+ a set with it so an un-checkpointed WAL loses nothing);
14
+ - ``unknown`` inside an AISC namespace but not allowlisted (likely the
15
+ user's own file) → quarantine candidate, never deleted;
16
+ - ``conflict`` owned but the target already exists with a different hash
17
+ → fail closed, user decision;
18
+ - namespace ``foreign``: e.g. a user's own ``.claude`` predating AISC — no
19
+ AISC-init marker anywhere → reported, not migrated.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Dict, List, Tuple
26
+
27
+ MIGRATION_PROTOCOL = "aisc.data-migration/v1"
28
+ MIGRATION_SCHEMA_VERSION = 1
29
+
30
+ # Manifest lifecycle states (closed set, 02-domain-contract.md).
31
+ STATE_PREPARED = "prepared"
32
+ STATE_COMMITTED = "committed"
33
+ STATE_ROLLED_BACK = "rolled_back"
34
+ STATE_QUARANTINED = "quarantined"
35
+ MANIFEST_STATES = (STATE_PREPARED, STATE_COMMITTED, STATE_ROLLED_BACK, STATE_QUARANTINED)
36
+
37
+ ENTRY_OWNED = "owned"
38
+ ENTRY_TRANSIENT = "transient"
39
+ ENTRY_UNKNOWN = "unknown"
40
+ ENTRY_CONFLICT = "conflict"
41
+ ENTRY_CLASSIFICATIONS = (ENTRY_OWNED, ENTRY_TRANSIENT, ENTRY_UNKNOWN, ENTRY_CONFLICT)
42
+
43
+ # Per-entry migration status (7d sets these; scan drafts stay "pending").
44
+ STATUS_PENDING = "pending"
45
+ STATUS_COPIED = "copied"
46
+ STATUS_SKIPPED = "skipped"
47
+ STATUS_QUARANTINED = "quarantined"
48
+
49
+ NAMESPACE_FOREIGN = "foreign"
50
+ NAMESPACE_AISC = "aisc"
51
+
52
+ # Namespace redirect marker left after a namespace fully migrates (risk
53
+ # table: old entries must find a read-only redirect/diagnostic, not a
54
+ # silently diverging copy). Content: JSON with schema/workspace_hash/root.
55
+ MARKER_NAME = ".aisc-migrated"
56
+
57
+ # Execution error codes (7d) — exit mapping stays with the CLI layer.
58
+ ERR_CONFLICT = "AISC_ERR_DATA_MIGRATION_CONFLICT"
59
+ ERR_UNKNOWN_PENDING = "AISC_ERR_DATA_MIGRATION_UNKNOWN_PENDING"
60
+ ERR_SOURCE_CHANGED = "AISC_ERR_DATA_MIGRATION_SOURCE_CHANGED"
61
+ ERR_INSUFFICIENT_SPACE = "AISC_ERR_DATA_MIGRATION_INSUFFICIENT_SPACE"
62
+ ERR_CORRUPT_COPY = "AISC_ERR_DATA_MIGRATION_CORRUPT_COPY"
63
+
64
+ # -- allowlist ---------------------------------------------------------------
65
+ # Keys are workspace-relative namespaces; values: (exact_files, dir_prefixes,
66
+ # transient_files, transient_prefixes). Everything else inside the namespace
67
+ # classifies as ``unknown`` (quarantine candidate, never deleted).
68
+
69
+ ALLOWLIST: Dict[str, Tuple[Tuple[str, ...], Tuple[str, ...], Tuple[str, ...], Tuple[str, ...]]] = {
70
+ ".aisc": (
71
+ # containers.json/state.env → runtime/; config.json → workspace config
72
+ ("containers.json", "state.env", "config.json"),
73
+ (),
74
+ (".containers.lock", MARKER_NAME),
75
+ ("workspace-locks/",),
76
+ ),
77
+ ".claude": (
78
+ # .claude.json (agent runtime config) and .factory-version (factory
79
+ # marker) are hidden dotfiles found by the real-workspace scan.
80
+ ("CLAUDE.md", "config.json", "settings.json", "settings.local.json",
81
+ ".claude.json", ".factory-version"),
82
+ ("backups/", "commands/", "plugins/", "projects/", "sessions/", "skills/"),
83
+ (MARKER_NAME,),
84
+ (),
85
+ ),
86
+ ".codex": (
87
+ ("config.toml", "AGENTS.md", ".factory-version"),
88
+ ("skills/",),
89
+ (MARKER_NAME,),
90
+ (),
91
+ ),
92
+ ".cc-switch": (
93
+ # db migrates together with -shm/-wal (un-checkpointed WAL safety).
94
+ ("cc-switch.db", "cc-switch.db-shm", "cc-switch.db-wal", "settings.json",
95
+ "session-scan-cache.db", ".aisc-bundled-skills.sha256",
96
+ ".aisc-preset-providers-claude.sha256", ".aisc-preset-providers-codex.sha256"),
97
+ ("skills/",),
98
+ ("cc-switch.db.init.lock", "state-mutation.lock", ".aisc-bundled-skills.lock",
99
+ MARKER_NAME),
100
+ (),
101
+ ),
102
+ # daemon runtime only: logs/pids are transient, anything else → unknown
103
+ ".local": ((), (), (), ("state/cc-switch/",)),
104
+ }
105
+
106
+ # Namespace → target layout under workspaces/<hash>/ ("" = file at the
107
+ # workspace-dir root). Only used for ``owned`` entries.
108
+ TARGET_DIRS: Dict[str, str] = {
109
+ ".claude": "claude",
110
+ ".codex": "codex",
111
+ ".cc-switch": "cc-switch",
112
+ }
113
+ TARGET_FILES: Dict[str, str] = {
114
+ ".aisc/containers.json": "runtime/containers.json",
115
+ ".aisc/state.env": "runtime/state.env",
116
+ ".aisc/config.json": "config.json",
117
+ }
118
+
119
+ # A workspace is "AISC-initialized" when at least one of these exists; a
120
+ # namespace dir without any marker classifies as the user's own (foreign).
121
+ AISC_INIT_MARKERS: Tuple[str, ...] = (
122
+ ".aisc",
123
+ ".codex/.factory-version",
124
+ ".cc-switch/.aisc-bundled-skills.sha256",
125
+ ".cc-switch/.aisc-preset-providers-claude.sha256",
126
+ )
127
+
128
+
129
+ def classify(namespace: str, rel: str) -> Tuple[str, str]:
130
+ """Pure classification of one namespace-relative file →
131
+ ``(classification, target_rel_under_workspace_dir)``.
132
+
133
+ ``rel`` is POSIX-style, relative to the namespace root (e.g.
134
+ ``skills/caveman/SKILL.md``); for ``.local`` it carries the ``state/…``
135
+ tail (e.g. ``state/cc-switch/cc-switchd.log``).
136
+ """
137
+ files, prefixes, transient_files, transient_prefixes = ALLOWLIST[namespace]
138
+ name = rel.rsplit("/", 1)[-1]
139
+
140
+ if name in transient_files or any(rel.startswith(p) for p in transient_prefixes):
141
+ return ENTRY_TRANSIENT, ""
142
+
143
+ if namespace == ".local":
144
+ # Only the cc-switch daemon subtree is in scope; its allowlist is all
145
+ # transient, so anything else here is unknown.
146
+ return ENTRY_UNKNOWN, ""
147
+
148
+ if namespace == ".aisc":
149
+ # Explicit file map only (state → runtime/, config → ws-dir root).
150
+ exact = TARGET_FILES.get(f".aisc/{rel}", "")
151
+ if exact:
152
+ return ENTRY_OWNED, exact
153
+ return ENTRY_UNKNOWN, ""
154
+
155
+ # Agent namespaces (.claude/.codex/.cc-switch): exact root files plus
156
+ # allowlisted subtrees map under workspaces/<hash>/<namespace-dir>/.
157
+ target_dir = TARGET_DIRS.get(namespace, "")
158
+ if not target_dir:
159
+ return ENTRY_UNKNOWN, ""
160
+ if (name in files and "/" not in rel) or any(rel.startswith(p) for p in prefixes):
161
+ return ENTRY_OWNED, f"{target_dir}/{rel}"
162
+ return ENTRY_UNKNOWN, ""
163
+
164
+
165
+ @dataclass
166
+ class MigrationEntry:
167
+ """One classified file (scan draft) / migrated file (7d status update).
168
+
169
+ Deliberately mutable (unlike the resolver results): the migration
170
+ journal advances ``status`` as prepare/copy/commit/rollback proceed.
171
+ """
172
+
173
+ relative: str # workspace-relative POSIX path, e.g. ".cc-switch/settings.json"
174
+ classification: str # ENTRY_*
175
+ sha256: str = ""
176
+ size: int = 0
177
+ target: str = "" # rel path under workspaces/<hash>/ ("" = not migrated)
178
+ status: str = STATUS_PENDING
179
+
180
+ def to_dict(self) -> Dict[str, Any]:
181
+ d: Dict[str, Any] = {
182
+ "relative": self.relative,
183
+ "classification": self.classification,
184
+ "size": self.size,
185
+ "status": self.status,
186
+ }
187
+ if self.sha256:
188
+ d["sha256"] = self.sha256
189
+ if self.target:
190
+ d["target"] = self.target
191
+ return d
192
+
193
+ @classmethod
194
+ def from_dict(cls, raw: Dict[str, Any]) -> "MigrationEntry":
195
+ if not isinstance(raw, dict):
196
+ raise ValueError("migration entry must be a JSON object")
197
+ classification = raw.get("classification", "")
198
+ if classification not in ENTRY_CLASSIFICATIONS:
199
+ raise ValueError(f"unsupported entry classification: {classification!r}")
200
+ return cls(
201
+ relative=raw.get("relative", ""),
202
+ classification=classification,
203
+ sha256=raw.get("sha256", ""),
204
+ size=int(raw.get("size", 0)),
205
+ target=raw.get("target", ""),
206
+ status=raw.get("status", STATUS_PENDING),
207
+ )
208
+
209
+
210
+ @dataclass
211
+ class MigrationManifest:
212
+ """``aisc.data-migration/v1`` — the migration boundary: rollback only
213
+ touches what this manifest lists; unknown fields fail closed."""
214
+
215
+ schema: str = MIGRATION_PROTOCOL
216
+ schema_version: int = MIGRATION_SCHEMA_VERSION
217
+ workspace_hash: str = ""
218
+ source: str = "" # legacy workspace root (abs)
219
+ target: str = "" # data-root workspaces/<hash>/ (abs)
220
+ entries: List[MigrationEntry] = field(default_factory=list)
221
+ # Namespace dirs where this migration wrote a redirect marker (rollback
222
+ # removes them). Dataclass field ordering: keep after entries.
223
+ markers: List[str] = field(default_factory=list)
224
+ state: str = STATE_PREPARED
225
+
226
+ def to_dict(self) -> Dict[str, Any]:
227
+ return {
228
+ "schema": self.schema,
229
+ "schema_version": self.schema_version,
230
+ "workspace_hash": self.workspace_hash,
231
+ "source": self.source,
232
+ "target": self.target,
233
+ "entries": [e.to_dict() for e in self.entries],
234
+ "markers": list(self.markers),
235
+ "state": self.state,
236
+ }
237
+
238
+ @classmethod
239
+ def from_dict(cls, raw: Dict[str, Any]) -> "MigrationManifest":
240
+ if not isinstance(raw, dict):
241
+ raise ValueError("migration manifest must be a JSON object")
242
+ if raw.get("schema") != MIGRATION_PROTOCOL:
243
+ raise ValueError(f"unsupported manifest schema: {raw.get('schema')!r}")
244
+ version = raw.get("schema_version", MIGRATION_SCHEMA_VERSION)
245
+ if version != MIGRATION_SCHEMA_VERSION:
246
+ raise ValueError(f"unsupported schema_version: {version}")
247
+ state = raw.get("state", STATE_PREPARED)
248
+ if state not in MANIFEST_STATES:
249
+ raise ValueError(f"unsupported manifest state: {state!r}")
250
+ return cls(
251
+ schema=MIGRATION_PROTOCOL,
252
+ schema_version=version,
253
+ workspace_hash=raw.get("workspace_hash", ""),
254
+ source=raw.get("source", ""),
255
+ target=raw.get("target", ""),
256
+ entries=[MigrationEntry.from_dict(e) for e in raw.get("entries", [])],
257
+ markers=list(raw.get("markers", [])),
258
+ state=state,
259
+ )