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,659 @@
1
+ """Docker resource lifecycle service (docker-resource-lifecycle A1/B).
2
+
3
+ Centralized scan / classification / cleanup / rebuild over AISC-owned
4
+ containers and workstation images. Every installer calls THIS through the
5
+ ``aisc maintenance`` CLI — platform scripts never reimplement the filter
6
+ rules (02 §1/§2/§3/§4).
7
+
8
+ Ownership tiers (frozen):
9
+ - ``owned`` — labels prove it (io.aisc.managed / org.aisc.managed);
10
+ - ``legacy_owned`` — no labels but legacy evidence: registry record, the
11
+ ``aisc-wb-`` name pattern, or the historical
12
+ ``super-claude-station-`` name + super-claude repo
13
+ (containers); the exact default tag
14
+ ``super-claude:latest`` in an upgrade/uninstall
15
+ context (images);
16
+ - ``unverified`` — merely looks AISC-ish (name/repository similar).
17
+ Reported, NEVER deleted.
18
+
19
+ Safety invariants (02 §5): no global prune; never delete by repository
20
+ name alone; containers before images; volumes/networks untouched; Docker
21
+ unavailable never concludes "not_found"; re-scan before deleting; argv
22
+ and logs never carry secrets.
23
+
24
+ All Docker I/O rides machine formats (ps/images templates, inspect
25
+ ``{{.Id}}``) — never human-text parsing. Cleanup/rebuild hold the shared
26
+ docker-maintenance lock (cross-plan order: maintenance -> workspace ->
27
+ registry). The runtime-create critical section (02 §4.1) attaches when
28
+ Stage C wires the installers' rebuild flow.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from pathlib import Path
35
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
36
+
37
+ from aisc.domain.models import CliError, RuntimeErrorCode, RuntimeExitCode
38
+
39
+ #: Exact default workstation tag (legacy evidence in upgrade/uninstall).
40
+ DEFAULT_IMAGE_TAG = "super-claude:latest"
41
+
42
+ SCAN_SCHEMA = "aisc.docker-scan/v1"
43
+ CLEANUP_SCHEMA = "aisc.docker-cleanup/v1"
44
+ REBUILD_SCHEMA = "aisc.docker-rebuild/v1"
45
+
46
+ #: docker ps template: id, name, image ref, status + the AISC labels.
47
+ _PS_FORMAT = (
48
+ "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t"
49
+ '{{.Label "io.aisc.managed"}}\t{{.Label "io.aisc.kind"}}\t'
50
+ '{{.Label "io.aisc.owner"}}'
51
+ )
52
+ #: docker images template (no .Labels placeholder exists on `docker
53
+ #: images` — ownership comes from the server-side label filter below).
54
+ _IMAGES_FORMAT = "{{.Repository}} {{.Tag}} {{.ID}}"
55
+
56
+
57
+ def _ps_rows(executor: Any) -> List[Dict[str, str]]:
58
+ try:
59
+ result = executor.run_captured(
60
+ ["ps", "-a", "--format", _PS_FORMAT], timeout=15.0
61
+ )
62
+ except Exception:
63
+ return []
64
+ if getattr(result, "exit_code", 1) != 0:
65
+ return []
66
+ rows: List[Dict[str, str]] = []
67
+ for line in (result.stdout or "").splitlines():
68
+ parts = [p.strip() for p in line.split("\t")]
69
+ if len(parts) < 4:
70
+ continue
71
+ rows.append({
72
+ "id": parts[0], "name": parts[1], "image": parts[2],
73
+ "status": parts[3],
74
+ "managed": parts[4] if len(parts) > 4 else "",
75
+ "kind": parts[5] if len(parts) > 5 else "",
76
+ "owner": parts[6] if len(parts) > 6 else "",
77
+ })
78
+ return rows
79
+
80
+
81
+ def _image_universe(executor: Any) -> List[Dict[str, Any]]:
82
+ """All images as {repository, tag, id, ref} rows (no labels)."""
83
+ try:
84
+ result = executor.run_captured(
85
+ ["images", "--format", _IMAGES_FORMAT], timeout=15.0
86
+ )
87
+ except Exception:
88
+ return []
89
+ if getattr(result, "exit_code", 1) != 0:
90
+ return []
91
+ rows: List[Dict[str, Any]] = []
92
+ for line in (result.stdout or "").splitlines():
93
+ parts = [p.strip() for p in line.split(" ")]
94
+ if len(parts) < 3:
95
+ continue
96
+ rows.append({
97
+ "repository": parts[0], "tag": parts[1], "id": parts[2],
98
+ "ref": f"{parts[0]}:{parts[1]}" if parts[1] != "<none>" else "",
99
+ })
100
+ return rows
101
+
102
+
103
+ def _filtered_image_ids(executor: Any, flag: str) -> List[str]:
104
+ """Image IDs passing a server-side ``docker images --filter``."""
105
+ try:
106
+ result = executor.run_captured(
107
+ ["images", "--filter", flag, "--format", "{{.ID}}"], timeout=15.0
108
+ )
109
+ except Exception:
110
+ return []
111
+ if getattr(result, "exit_code", 1) != 0:
112
+ return []
113
+ return [ln.strip() for ln in (result.stdout or "").splitlines() if ln.strip()]
114
+
115
+
116
+ def _registry_evidence(data_root: Optional[Path]) -> Dict[str, Dict[str, Any]]:
117
+ """All registry records across workspaces (legacy container evidence)."""
118
+ from aisc.adapters.container_registry import list_containers_readonly
119
+ from aisc.application.data_root import shared_root
120
+
121
+ root = Path(data_root) if data_root else shared_root()
122
+ evidence: Dict[str, Dict[str, Any]] = {}
123
+ ws_root = root / "workspaces"
124
+ if not ws_root.is_dir():
125
+ return evidence
126
+ for ws_dir in ws_root.iterdir():
127
+ reg = ws_dir / "runtime"
128
+ if not reg.is_dir():
129
+ continue
130
+ try:
131
+ for name, meta in list_containers_readonly(reg).items():
132
+ if isinstance(meta, dict):
133
+ evidence[name] = meta
134
+ except Exception:
135
+ continue # one unreadable workspace registry never breaks the scan
136
+ return evidence
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Classification
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def classify_containers(
144
+ rows: Iterable[Dict[str, str]], registry: Dict[str, Dict[str, Any]]
145
+ ) -> Dict[str, List[Dict[str, Any]]]:
146
+ buckets: Dict[str, List[Dict[str, Any]]] = {
147
+ "owned": [], "legacy_owned": [], "unverified": [],
148
+ }
149
+ for row in rows:
150
+ name = row["name"]
151
+ image_repo = row["image"].split(":")[0]
152
+ if row["managed"] == "true":
153
+ ownership, reason = "owned", "label"
154
+ elif name in registry:
155
+ ownership, reason = "legacy_owned", "registry"
156
+ elif name.startswith("aisc-wb-"):
157
+ ownership, reason = "legacy_owned", "legacy-name"
158
+ elif name.startswith("super-claude-station-") and image_repo == "super-claude":
159
+ ownership, reason = "legacy_owned", "legacy-name"
160
+ elif image_repo == "super-claude" or "aisc" in name.lower():
161
+ ownership, reason = "unverified", "repository-only"
162
+ else:
163
+ continue # not AISC-adjacent — none of our business
164
+ buckets[ownership].append({
165
+ "id": row["id"], "name": name, "image": row["image"],
166
+ "ownership": ownership,
167
+ "state": "running" if row["status"].startswith("Up") else "stopped",
168
+ "reason": reason,
169
+ })
170
+ return buckets
171
+
172
+
173
+ def classify_images(
174
+ rows: Iterable[Dict[str, Any]],
175
+ *,
176
+ context: str,
177
+ owned_ids: Iterable[str] = (),
178
+ dangling_ids: Iterable[str] = (),
179
+ old_image_ids: Iterable[str] = (),
180
+ ) -> Tuple[Dict[str, List[Dict[str, Any]]], List[Dict[str, Any]]]:
181
+ """Three-tier image classification + evidence-backed dangling set.
182
+
183
+ ``owned_ids`` come from the server-side ``label=org.aisc.managed=true``
184
+ filter (docker images has no label template — the filter is the only
185
+ reliable source); ``dangling_ids`` from ``dangling=true``.
186
+ """
187
+ buckets: Dict[str, List[Dict[str, Any]]] = {
188
+ "owned": [], "legacy_owned": [], "unverified": [],
189
+ }
190
+ dangling: List[Dict[str, Any]] = []
191
+ owned = {i for i in owned_ids if i}
192
+ dangling_set = {i for i in dangling_ids if i}
193
+ old_ids = {i for i in old_image_ids if i}
194
+ for row in rows:
195
+ entry = {
196
+ "id": row["id"], "name": row["ref"] or row["id"],
197
+ "image": row["ref"] or row["id"],
198
+ }
199
+ if row["repository"] == "<none>":
200
+ # Dangling FIRST (no ref to untag — deletable by ID only).
201
+ # Evidence: org.aisc label (survives on the image config) or the
202
+ # upgrade's captured old_image_id (temporary proof).
203
+ if row["id"] in owned or row["id"] in old_ids:
204
+ dangling.append({
205
+ "id": row["id"], "name": row["id"], "image": row["id"],
206
+ "ownership": "owned", "state": "dangling",
207
+ "reason": "label" if row["id"] in owned else "upgrade-old-id",
208
+ })
209
+ elif row["id"] in owned:
210
+ entry.update({"ownership": "owned", "state": "present", "reason": "label"})
211
+ buckets["owned"].append(entry)
212
+ elif row["ref"] == DEFAULT_IMAGE_TAG and context != "first_install":
213
+ entry.update({"ownership": "legacy_owned", "state": "present",
214
+ "reason": "default-tag"})
215
+ buckets["legacy_owned"].append(entry)
216
+ elif row["repository"] == "super-claude":
217
+ entry.update({"ownership": "unverified", "state": "present",
218
+ "reason": "repository-only"})
219
+ buckets["unverified"].append(entry)
220
+ else:
221
+ continue # not AISC-adjacent — none of our business
222
+ return buckets, dangling
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Scan
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def docker_scan(
230
+ executor: Any,
231
+ *,
232
+ context: str = "upgrade",
233
+ old_image_ids: Iterable[str] = (),
234
+ data_root: Optional[Path] = None,
235
+ ) -> Dict[str, Any]:
236
+ """One read-only classification pass (02 §2 envelope)."""
237
+ from aisc.application.runtime import _check_docker
238
+
239
+ available = _check_docker(executor)
240
+ payload: Dict[str, Any] = {
241
+ "schema_version": SCAN_SCHEMA,
242
+ "docker": {"available": available, "reason": "ok" if available else "unavailable"},
243
+ "containers": {"owned": [], "legacy_owned": [], "unverified": []},
244
+ "images": {"owned": [], "legacy_owned": [], "unverified": []},
245
+ "dangling_owned": [],
246
+ "warnings": [],
247
+ }
248
+ if not available:
249
+ return payload
250
+ registry = _registry_evidence(data_root)
251
+ payload["containers"] = classify_containers(_ps_rows(executor), registry)
252
+ payload["images"], payload["dangling_owned"] = classify_images(
253
+ _image_universe(executor),
254
+ context=context,
255
+ owned_ids=_filtered_image_ids(executor, "label=org.aisc.managed=true"),
256
+ dangling_ids=_filtered_image_ids(executor, "dangling=true"),
257
+ old_image_ids=old_image_ids,
258
+ )
259
+ return payload
260
+
261
+
262
+ def render_scan_text(payload: Dict[str, Any]) -> str:
263
+ """Space-separated one-line-per-resource text for installers and shell
264
+ scripts (NSIS/Inno/POSIX sh cannot parse JSON cheaply; this format is
265
+ stable: ``<kind> <ownership> <id> <name>``).
266
+
267
+ Example lines::
268
+
269
+ docker available
270
+ container owned cid123 aisc-wb-1
271
+ image owned sha256:abc super-claude:latest
272
+ """
273
+ lines = [
274
+ "docker available" if payload.get("docker", {}).get("available")
275
+ else "docker unavailable"
276
+ ]
277
+ for kind in ("containers", "images"):
278
+ buckets = payload.get(kind, {})
279
+ for ownership in ("owned", "legacy_owned", "unverified"):
280
+ for entry in buckets.get(ownership, []):
281
+ lines.append(
282
+ f"{kind.rstrip('s')} {ownership} {entry.get('id', '')} "
283
+ f"{entry.get('name', '')}"
284
+ )
285
+ for entry in payload.get("dangling_owned", []):
286
+ lines.append(f"image dangling-owned {entry.get('id', '')} {entry.get('id', '')}")
287
+ return "\n".join(lines)
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Cleanup (02 §3 order; per-resource failure never aborts the rest)
292
+ # ---------------------------------------------------------------------------
293
+
294
+ def _is_no_such(stderr: str) -> bool:
295
+ low = (stderr or "").lower()
296
+ return "no such" in low or "not found" in low
297
+
298
+
299
+ def docker_cleanup(
300
+ executor: Any,
301
+ *,
302
+ context: str = "upgrade",
303
+ old_image_ids: Iterable[str] = (),
304
+ data_root: Optional[Path] = None,
305
+ ) -> Dict[str, Any]:
306
+ """Upgrade context cleans CONTAINERS ONLY (01 §3 upgrade ordering):
307
+ the tagged image must survive until docker-rebuild succeeds, or a
308
+ failed rebuild would leave the user with no workstation image at all;
309
+ the old image is then removed by ID via rebuild's old-ID handoff."""
310
+ result: Dict[str, Any] = {
311
+ "schema_version": CLEANUP_SCHEMA,
312
+ "action": "cleanup",
313
+ "containers": {"removed": [], "not_found": [], "failed": []},
314
+ "images": {"removed": [], "not_found": [], "failed": []},
315
+ "skipped_unverified": [],
316
+ "warnings": [],
317
+ }
318
+
319
+ def _run(argv: List[str], timeout: float = 60.0):
320
+ return executor.run_captured(argv, timeout=timeout)
321
+
322
+ # Re-scan + re-verify (invariant 6: never trust a stale list).
323
+ scan = docker_scan(executor, context=context, old_image_ids=old_image_ids,
324
+ data_root=data_root)
325
+ if not scan["docker"]["available"]:
326
+ raise CliError(
327
+ message="Docker unavailable; cleanup refused (nothing concluded)",
328
+ exit_code=RuntimeExitCode.DOCKER_UNAVAILABLE,
329
+ error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
330
+ )
331
+ for entry in scan["containers"]["unverified"] + scan["images"]["unverified"]:
332
+ result["skipped_unverified"].append(entry["name"])
333
+
334
+ from aisc.adapters.maintenance_lock import docker_maintenance_lock_at_root
335
+ from aisc.application.data_root import shared_root
336
+
337
+ root = Path(data_root) if data_root else shared_root()
338
+
339
+ with docker_maintenance_lock_at_root(root):
340
+ # 1-3. containers first (order: stop orderly, then force remove).
341
+ for entry in scan["containers"]["owned"] + scan["containers"]["legacy_owned"]:
342
+ name = entry["name"]
343
+ if entry["state"] == "running":
344
+ stop = _run(["stop", "-t", "10", name], timeout=30.0)
345
+ if stop.exit_code != 0 and not _is_no_such(stop.stderr):
346
+ result["containers"]["failed"].append(name)
347
+ continue
348
+ rm = _run(["rm", "-f", name], timeout=60.0)
349
+ if rm.exit_code == 0 or _is_no_such(rm.stderr):
350
+ bucket = "removed" if rm.exit_code == 0 else "not_found"
351
+ result["containers"][bucket].append(name)
352
+ else:
353
+ result["containers"]["failed"].append(name)
354
+
355
+ # 4-6. re-scan image references, then untag/delete by evidence.
356
+ # Upgrade: images ride the rebuild handoff — skip entirely.
357
+ removed_ids = set()
358
+ if context == "upgrade":
359
+ scan2 = {"images": {"owned": [], "legacy_owned": [], "unverified": []},
360
+ "dangling_owned": []}
361
+ else:
362
+ scan2 = docker_scan(executor, context=context, old_image_ids=old_image_ids,
363
+ data_root=data_root)
364
+ for entry in scan2["images"]["owned"] + scan2["images"]["legacy_owned"]:
365
+ ref = entry["image"]
366
+ if entry["id"] in removed_ids:
367
+ continue
368
+ rm = _run(["rmi", ref], timeout=120.0)
369
+ if rm.exit_code == 0:
370
+ result["images"]["removed"].append(ref)
371
+ removed_ids.add(entry["id"])
372
+ elif _is_no_such(rm.stderr):
373
+ result["images"]["not_found"].append(ref)
374
+ else:
375
+ # referenced by another tag/container or refused — kept.
376
+ result["images"]["failed"].append(ref)
377
+ result["warnings"].append(
378
+ f"image kept (still referenced or refused): {ref}"
379
+ )
380
+ # 7. dangling with evidence.
381
+ for entry in scan2["dangling_owned"]:
382
+ if entry["id"] in removed_ids:
383
+ continue
384
+ rm = _run(["rmi", entry["id"]], timeout=120.0)
385
+ if rm.exit_code == 0:
386
+ result["images"]["removed"].append(entry["id"])
387
+ removed_ids.add(entry["id"])
388
+ elif not _is_no_such(rm.stderr):
389
+ result["images"]["failed"].append(entry["id"])
390
+ # Volumes/networks: deliberately nothing (invariant 4).
391
+ return result
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Rebuild (02 §4)
396
+ # ---------------------------------------------------------------------------
397
+
398
+ def _cc_switch_for_rebuild() -> Any:
399
+ """Deterministic-offline cc-switch pin for installer rebuilds.
400
+
401
+ Priority: explicit cached version (any age — a pinned digest is stable
402
+ content, and installers must not depend on reaching the GitHub API) →
403
+ live ``latest`` → None (Dockerfile ARG fallback, documented
404
+ non-reproducible; the result carries a warning).
405
+ """
406
+ import json as _json
407
+ from aisc.application.cc_switch_resolver import CcSwitchResolver
408
+ from aisc.application.data_root import shared_root
409
+
410
+ resolver = CcSwitchResolver()
411
+ cache = shared_root() / "cache" / "cc-switch" / "last-resolved.json"
412
+ version = ""
413
+ try:
414
+ version = str(_json.loads(cache.read_text(encoding="utf-8")).get("version") or "")
415
+ except (OSError, ValueError):
416
+ version = ""
417
+ for candidate in ([version] if version else []) + ["latest"]:
418
+ try:
419
+ return resolver.resolve(version=candidate)
420
+ except Exception:
421
+ continue
422
+ return None
423
+
424
+
425
+ _CC_UNSET = object()
426
+
427
+
428
+ def docker_rebuild(
429
+ executor: Any,
430
+ *,
431
+ root: str,
432
+ tag: str = DEFAULT_IMAGE_TAG,
433
+ old_image_id: str = "",
434
+ no_cache: bool = True,
435
+ pull: bool = False,
436
+ cc_switch: Any = _CC_UNSET,
437
+ ) -> Dict[str, Any]:
438
+ """No-cache rebuild of the workstation image with old-ID handoff.
439
+
440
+ ``root`` is the BUNDLE ROOT (aisc-root shaped: ``container/Dockerfile``
441
+ + ``config/versions.env`` — validated like ``aisc build``), matching
442
+ the installed ``$INSTDIR/aisc-bundle``. cc-switch is pinned from the
443
+ resolver cache when available (offline-deterministic; installers must
444
+ not require the GitHub API), falling back to the Dockerfile ARG path
445
+ with a warning.
446
+
447
+ Returns the 02 §4 result: old/new image ids, image_changed,
448
+ old_image_action (removed | untagged | kept_referenced | not_found),
449
+ reconcile_hint. Build failure PRESERVES the old image and reports
450
+ failed=true (the caller decides the UX; nothing is deleted on failure).
451
+ """
452
+ from aisc.adapters.maintenance_lock import docker_maintenance_lock_at_root
453
+ from aisc.application.data_root import shared_root
454
+ from aisc.cli.commands.build import plan_build
455
+
456
+ if cc_switch is _CC_UNSET:
457
+ cc_switch = _cc_switch_for_rebuild()
458
+ warnings: List[str] = []
459
+ if cc_switch is None:
460
+ warnings.append(
461
+ "cc-switch not pinned (no resolver cache, live resolve failed); "
462
+ "built via Dockerfile ARG fallback"
463
+ )
464
+ try:
465
+ plan = plan_build(
466
+ Path(root), tag=tag, no_cache=no_cache, pull=pull,
467
+ dry_run=False, cc_switch=cc_switch,
468
+ )
469
+ except CliError as exc:
470
+ raise CliError(
471
+ message=f"bundle validation failed: {exc.message}",
472
+ exit_code=RuntimeExitCode.USAGE_ERROR,
473
+ error_code=RuntimeErrorCode.WORKSPACE_INVALID,
474
+ ) from exc
475
+
476
+ with docker_maintenance_lock_at_root(shared_root()):
477
+ build = executor.run_captured(plan.docker_argv, timeout=1800.0)
478
+ new_id = _image_id_by_ref(executor, tag)
479
+ if build.exit_code != 0 or not new_id:
480
+ return {
481
+ "schema_version": REBUILD_SCHEMA,
482
+ "tag": tag,
483
+ "old_image_id": old_image_id,
484
+ "new_image_id": "",
485
+ "image_changed": False,
486
+ "old_image_action": "not_found" if not old_image_id else "kept_referenced",
487
+ "reconcile_hint": "unchanged",
488
+ "failed": True,
489
+ "warnings": warnings,
490
+ "build_log_tail": _log_tail(build.stdout or ""),
491
+ }
492
+ changed = bool(old_image_id) and old_image_id != new_id
493
+ action = "not_found"
494
+ if old_image_id and changed:
495
+ rm = executor.run_captured(["rmi", old_image_id], timeout=120.0)
496
+ if rm.exit_code == 0:
497
+ action = "removed"
498
+ elif _is_no_such(rm.stderr):
499
+ action = "not_found"
500
+ else:
501
+ action = "kept_referenced" # still tagged elsewhere / in use
502
+ return {
503
+ "schema_version": REBUILD_SCHEMA,
504
+ "tag": tag,
505
+ "old_image_id": old_image_id,
506
+ "new_image_id": new_id,
507
+ "image_changed": changed,
508
+ "old_image_action": action,
509
+ "reconcile_hint": "image_changed" if changed else "unchanged",
510
+ "failed": False,
511
+ "warnings": warnings,
512
+ "build_log_tail": _log_tail(build.stdout or ""),
513
+ }
514
+
515
+
516
+ def _image_id_by_ref(executor: Any, ref: str) -> str:
517
+ try:
518
+ result = executor.run_captured(
519
+ ["image", "inspect", ref, "--format", "{{.Id}}"], timeout=15.0
520
+ )
521
+ except Exception:
522
+ return ""
523
+ if getattr(result, "exit_code", 1) != 0:
524
+ return ""
525
+ return (result.stdout or "").strip()
526
+
527
+
528
+ def _log_tail(stdout: str, lines: int = 20) -> str:
529
+ tail = "\n".join(stdout.strip().splitlines()[-lines:])
530
+ # Redaction-by-construction: build output contains no secrets; cap the
531
+ # size so the envelope stays bounded either way.
532
+ return tail[-4000:]
533
+
534
+
535
+ # ---------------------------------------------------------------------------
536
+ # O7 (opt-batch, D-11): build-cache cleanup — builder prune + dangling images
537
+ # with filters. Extends the module's safety invariants: NEVER a global
538
+ # `docker system prune`, NEVER `-a` (non-dangling images stay), and the
539
+ # `until=` filter keeps caches/entries younger than the window untouched.
540
+ # ---------------------------------------------------------------------------
541
+
542
+ CACHE_USAGE_SCHEMA = "aisc.docker-cache-usage/v1"
543
+ CACHE_CLEANUP_SCHEMA = "aisc.docker-cache-cleanup/v1"
544
+
545
+
546
+ def _cache_cleanup_argv(kind: str, min_age_hours: int) -> List[str]:
547
+ """Pure argv builder for the two prune kinds (test-pinned invariants).
548
+
549
+ builder → `docker builder prune --force --filter until=<h>h`
550
+ dangling → `docker image prune --force --filter until=<h>h` (NO -a)
551
+ """
552
+ if kind == "builder":
553
+ return ["builder", "prune", "--force",
554
+ "--filter", f"until={min_age_hours}h"]
555
+ if kind == "dangling":
556
+ # image prune WITHOUT -a removes DANGLING (<none>) images only.
557
+ return ["image", "prune", "--force",
558
+ "--filter", f"until={min_age_hours}h"]
559
+ raise ValueError(f"unknown cache-cleanup kind: {kind}")
560
+
561
+
562
+ def _system_df(executor: Any) -> Dict[str, Dict[str, str]]:
563
+ """`docker system df` rows keyed by type (Images/Containers/Local Volumes/
564
+ Build Cache). Unavailable docker → empty dict (caller surfaces it)."""
565
+ result = executor.run_captured(
566
+ ["system", "df", "--format", "{{json .}}"], timeout=30.0
567
+ )
568
+ import json as _json
569
+
570
+ rows: Dict[str, Dict[str, str]] = {}
571
+ if getattr(result, "exit_code", 1) != 0:
572
+ return rows
573
+ for line in (result.stdout or "").splitlines():
574
+ line = line.strip()
575
+ if not line.startswith("{"):
576
+ continue
577
+ try:
578
+ row = _json.loads(line)
579
+ except ValueError:
580
+ continue
581
+ kind = str(row.get("Type") or "")
582
+ if kind:
583
+ rows[kind] = {
584
+ "total_count": str(row.get("TotalCount") or ""),
585
+ "active": str(row.get("Active") or ""),
586
+ "size": str(row.get("Size") or ""),
587
+ "reclaimable": str(row.get("Reclaimable") or ""),
588
+ }
589
+ return rows
590
+
591
+
592
+ def cache_usage(executor: Any) -> Dict[str, Any]:
593
+ """Read-only df summary for the settings card."""
594
+ df = _system_df(executor)
595
+ return {
596
+ "schema_version": CACHE_USAGE_SCHEMA,
597
+ "docker_available": bool(df),
598
+ "df": df,
599
+ }
600
+
601
+
602
+ def docker_cache_cleanup(
603
+ executor: Any,
604
+ *,
605
+ min_age_hours: int = 24,
606
+ data_root: Optional[Path] = None,
607
+ ) -> Dict[str, Any]:
608
+ """Prune the build cache and dangling images (both until-filtered).
609
+
610
+ Never a global prune; never `-a`; running containers' layers are never
611
+ candidates (docker enforces this for both prune kinds). Reports the df
612
+ before/after deltas plus each prune's own reclaimed line.
613
+ """
614
+ if min_age_hours < 1:
615
+ raise CliError(
616
+ message="min_age_hours must be >= 1 (refusing unfiltered prune)",
617
+ exit_code=RuntimeExitCode.USAGE_ERROR,
618
+ error_code="AISC_ERR_USAGE",
619
+ )
620
+
621
+ from aisc.adapters.maintenance_lock import docker_maintenance_lock_at_root
622
+ from aisc.application.data_root import shared_root
623
+
624
+ root = Path(data_root) if data_root else shared_root()
625
+ before = _system_df(executor)
626
+ if not before:
627
+ raise CliError(
628
+ message="Docker unavailable; cache cleanup refused",
629
+ exit_code=RuntimeExitCode.DOCKER_UNAVAILABLE,
630
+ error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
631
+ )
632
+
633
+ result: Dict[str, Any] = {
634
+ "schema_version": CACHE_CLEANUP_SCHEMA,
635
+ "action": "cache-cleanup",
636
+ "min_age_hours": min_age_hours,
637
+ "prunes": [],
638
+ "df_before": before,
639
+ "df_after": {},
640
+ "warnings": [],
641
+ }
642
+ with docker_maintenance_lock_at_root(root):
643
+ for kind in ("builder", "dangling"):
644
+ argv = _cache_cleanup_argv(kind, min_age_hours)
645
+ pr = executor.run_captured(argv, timeout=300.0)
646
+ reclaimed = ""
647
+ for line in (pr.stdout or "").splitlines():
648
+ if "reclaimed" in line.lower():
649
+ reclaimed = line.strip()
650
+ break
651
+ entry = {"kind": kind, "argv": argv,
652
+ "exit_code": pr.exit_code, "reclaimed": reclaimed}
653
+ if pr.exit_code != 0:
654
+ entry["error"] = (pr.stderr or pr.stdout or "").strip()[:200]
655
+ result["warnings"].append(f"{kind} prune failed")
656
+ result["prunes"].append(entry)
657
+
658
+ result["df_after"] = _system_df(executor)
659
+ return result