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,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
|
+
)
|