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,318 @@
1
+ """Workspace reconcile (runtime-lifecycle-ux Stage 1, 02 §3).
2
+
3
+ One idempotent operation answering "may this Workbench instance start a
4
+ runtime in this workspace, and what shall it do with leftovers?" — the
5
+ frontend never assembles list/inspect/remove sequences itself (02 §3:
6
+ "前端不得先 list、过一段时间再无锁 remove").
7
+
8
+ Classification (frozen):
9
+
10
+ - ``clean`` — nothing to do; start.
11
+ - ``active_same_instance`` — this instance already holds the lease
12
+ (frontend focuses the existing workspace).
13
+ - ``stale_ephemeral`` — expired/legacy Workbench runtime recycled
14
+ (stop -> inspect -> remove -> prune);
15
+ safe to start.
16
+ - ``active_other_instance`` — a fresh lease belongs to another instance;
17
+ BLOCK (minimal block page in the UI).
18
+ - ``unknown_owner`` — registry/Docker evidence cannot prove AISC
19
+ ownership (owner missing / not workbench /
20
+ unverifiable); BLOCK, diagnostic path —
21
+ never auto-delete (safety invariant 1).
22
+ - ``stale_registry`` — registry entries whose containers are gone;
23
+ registry pruned (idempotently); start.
24
+ - ``docker_unavailable`` — no destructive action, no "not_found"
25
+ conclusions (safety invariant 3); BLOCK.
26
+
27
+ Lock order (frozen, cross-plan): docker-maintenance lock -> workspace
28
+ lock -> registry transaction. Reconcile is the reference implementer of
29
+ that order. Expiry of a lease NEVER authorizes deletion by itself — the
30
+ registry/Docker-label reconciliation below IS the required re-verification
31
+ (invariant 2; covers sleep-resume where the clock jumped past the TTL).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import time
37
+ from pathlib import Path
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ from aisc.adapters.container_registry import (
41
+ list_containers_readonly,
42
+ unregister,
43
+ )
44
+ from aisc.adapters.data_root_store import DataRootStore
45
+ from aisc.adapters.maintenance_lock import docker_maintenance_lock
46
+ from aisc.adapters.workspace_lease_store import WorkspaceLeaseStore
47
+ from aisc.application.data_root import DataRootResolver
48
+ from aisc.applog import append_event
49
+ from aisc.domain.models import RuntimeErrorCode
50
+ from aisc.domain.workspace_lease import WorkspaceLease
51
+
52
+ #: Envelope schema stamp (02 §3 JSON).
53
+ RECONCILE_SCHEMA = "aisc.runtime-reconcile/v1"
54
+
55
+ #: Registry metadata: lifecycle values reconcile recycles vs keeps.
56
+ LIFECYCLE_EPHEMERAL = "ephemeral"
57
+
58
+ #: retention values that keep the container alive across close (advanced).
59
+ RETENTION_KEEP = ("keep_stopped", "keep_running")
60
+
61
+
62
+ def _workspace_scoped_containers(ws_key: str, executor: Any) -> List[Dict[str, str]]:
63
+ """Docker containers labeled ``io.aisc.workspace-key=<ws_key>``.
64
+
65
+ Machine tab format via ``docker ps`` (labels ride the AISC ownership
66
+ contract; the classification never trusts names alone for new
67
+ resources). Empty on any Docker hiccup — callers treat that as
68
+ "docker-side evidence unavailable", never as "no containers".
69
+ """
70
+ try:
71
+ result = executor.run_captured(
72
+ [
73
+ "ps", "-a",
74
+ "--filter", f"label=io.aisc.workspace-key={ws_key}",
75
+ "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t"
76
+ "{{.Label \"io.aisc.runtime-id\"}}\t"
77
+ "{{.Label \"io.aisc.owner\"}}",
78
+ ],
79
+ timeout=10.0,
80
+ )
81
+ except Exception:
82
+ return []
83
+ if getattr(result, "exit_code", 1) != 0:
84
+ return []
85
+ rows: List[Dict[str, str]] = []
86
+ for line in (result.stdout or "").splitlines():
87
+ parts = [p.strip() for p in line.split("\t")]
88
+ if len(parts) >= 3:
89
+ rows.append({
90
+ "container_id": parts[0],
91
+ "container_name": parts[1],
92
+ "status": parts[2],
93
+ "runtime_id": parts[3] if len(parts) > 3 else "",
94
+ "owner": parts[4] if len(parts) > 4 else "",
95
+ })
96
+ return rows
97
+
98
+
99
+ def _status_to_state(status: str) -> str:
100
+ if not status:
101
+ return "unknown"
102
+ return "running" if status.startswith("Up") else "stopped"
103
+
104
+
105
+ def reconcile_workspace(
106
+ workspace: str,
107
+ instance_id: str,
108
+ executor: Any,
109
+ registry_root: Optional[Path] = None,
110
+ env: Optional[Dict[str, str]] = None,
111
+ ) -> Dict[str, Any]:
112
+ """Run one reconcile pass and return the frozen JSON envelope.
113
+
114
+ ``env`` overrides the data-root environment (hermetic tests). The
115
+ function reports; it never raises for classification outcomes — only
116
+ for programming errors. Destructive actions touch ONLY resources
117
+ proven owned (owner=workbench, ephemeral-or-legacy) after lease and
118
+ Docker-side verification, under maintenance -> workspace locks.
119
+ """
120
+ from aisc.adapters.container_registry import workspace_lock
121
+ from aisc.application.runtime import (
122
+ _check_docker,
123
+ _resolve_registry_root,
124
+ workspace_key_for,
125
+ )
126
+
127
+ canonical = str(Path(workspace).resolve())
128
+ ws_key = workspace_key_for(canonical)
129
+ reg_root = _resolve_registry_root(canonical, registry_root)
130
+
131
+ resolved = DataRootResolver(env=env).resolve(Path(canonical))
132
+ store = DataRootStore(resolved)
133
+ store.prepare()
134
+ leases = WorkspaceLeaseStore(store, ws_key)
135
+
136
+ def envelope(
137
+ classification: str,
138
+ *,
139
+ runtime_id: Optional[str],
140
+ can_proceed: bool,
141
+ cleanup: Dict[str, Any],
142
+ error_code: Optional[str] = None,
143
+ technical_detail: Optional[str] = None,
144
+ ) -> Dict[str, Any]:
145
+ payload: Dict[str, Any] = {
146
+ "schema_version": RECONCILE_SCHEMA,
147
+ "workspace_key": ws_key,
148
+ "classification": classification,
149
+ "runtime_id": runtime_id,
150
+ "can_proceed": can_proceed,
151
+ "cleanup": cleanup,
152
+ "observed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
153
+ "error_code": error_code,
154
+ "technical_detail": technical_detail,
155
+ }
156
+ action = {"clean": "skip", "stale_ephemeral": "remove", "stale_registry": "remove"}.get(
157
+ classification, "block"
158
+ )
159
+ result = "ok" if error_code is None else "error"
160
+ append_event(
161
+ "runtime_reconcile", source="cli",
162
+ workspace_hash=ws_key,
163
+ classification=classification,
164
+ action=action, result=result,
165
+ )
166
+ return payload
167
+
168
+ # --- 1. Fresh lease short-circuits everything -------------------------
169
+ lease: Optional[WorkspaceLease] = leases.inspect()
170
+ if lease is not None and not lease.is_expired():
171
+ if lease.held_by(instance_id):
172
+ return envelope(
173
+ "active_same_instance", runtime_id=None, can_proceed=False,
174
+ cleanup=_no_cleanup(),
175
+ )
176
+ return envelope(
177
+ "active_other_instance", runtime_id=None, can_proceed=False,
178
+ cleanup=_no_cleanup(),
179
+ error_code=RuntimeErrorCode.ACTIVE_WORKSPACE_LEASE,
180
+ technical_detail="workspace leased by another Workbench instance",
181
+ )
182
+
183
+ # --- 2. Docker unavailable: no destructive action, no conclusions ------
184
+ if not _check_docker(executor):
185
+ return envelope(
186
+ "docker_unavailable", runtime_id=None, can_proceed=False,
187
+ cleanup=_no_cleanup(),
188
+ error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
189
+ technical_detail="docker daemon/CLI unavailable; nothing removed",
190
+ )
191
+
192
+ # --- 3. Recycle stale owned runtimes under maintenance -> workspace ----
193
+ cleanup = _no_cleanup()
194
+ saw_unknown_owner = False
195
+ saw_recycled = False
196
+ saw_registry_prune = False
197
+ last_runtime_id: Optional[str] = None
198
+ failure: Optional[str] = None
199
+
200
+ with docker_maintenance_lock(store):
201
+ with workspace_lock(reg_root, ws_key, timeout=30.0):
202
+ try:
203
+ registry_entries = list_containers_readonly(reg_root)
204
+ except Exception as exc: # corrupted registry: fail closed
205
+ return envelope(
206
+ "unknown_owner", runtime_id=None, can_proceed=False,
207
+ cleanup=cleanup,
208
+ error_code=RuntimeErrorCode.RUNTIME_OWNER_UNKNOWN,
209
+ technical_detail=f"registry unreadable: {exc}",
210
+ )
211
+
212
+ docker_rows = _workspace_scoped_containers(ws_key, executor)
213
+ docker_by_name = {r["container_name"]: r for r in docker_rows}
214
+
215
+ targets: List[Dict[str, Any]] = []
216
+ for name, meta in registry_entries.items():
217
+ if not isinstance(meta, dict):
218
+ continue
219
+ if meta.get("workspace_key") != ws_key and meta.get("workspace") != canonical:
220
+ continue
221
+ targets.append({"name": name, "meta": meta, "source": "registry"})
222
+ for name, row in docker_by_name.items():
223
+ if name not in registry_entries:
224
+ targets.append({"name": name, "meta": {}, "source": "docker_only"})
225
+
226
+ for target in targets:
227
+ name = target["name"]
228
+ meta = target["meta"]
229
+ owner = str(meta.get("owner") or row_owner(docker_by_name.get(name)))
230
+ lifecycle = str(meta.get("lifecycle") or "")
231
+ retention = str(meta.get("retention") or "")
232
+ runtime_id = str(meta.get("runtime_id") or "")
233
+
234
+ # Owned = workbench-created and ephemeral (or legacy: no
235
+ # lifecycle field yet — product table row 3 recycles it).
236
+ # Anything else: non-ephemeral/keep policies are silently
237
+ # KEPT (advanced retention); unprovable ownership BLOCKS
238
+ # (unknown_owner, invariant 1 — never auto-deleted).
239
+ is_owned = owner == "workbench" and lifecycle in ("", LIFECYCLE_EPHEMERAL)
240
+ if not is_owned:
241
+ saw_unknown_owner = True
242
+ continue
243
+ if retention in RETENTION_KEEP:
244
+ continue
245
+
246
+ state = docker_state(name, executor, docker_by_name)
247
+ cleanup["attempted"] = True
248
+ last_runtime_id = runtime_id or None
249
+ if state == "not_found":
250
+ # Stale registry record: prune only, idempotent.
251
+ unregister(reg_root, name)
252
+ cleanup["registry_pruned"] = True
253
+ saw_registry_prune = True
254
+ continue
255
+ if state == "running":
256
+ stop = executor.stop_container(name, timeout=10)
257
+ if stop.exit_code != 0:
258
+ failure = f"stop failed for {name}"
259
+ continue
260
+ cleanup["stopped"] = True
261
+ rm = executor.remove_container(name, force=True)
262
+ if rm.exit_code != 0:
263
+ failure = f"remove failed for {name}"
264
+ continue
265
+ cleanup["removed"] = True
266
+ saw_recycled = True
267
+ if name in registry_entries:
268
+ unregister(reg_root, name)
269
+ cleanup["registry_pruned"] = True
270
+
271
+ # Idempotency guard: a second pass must see nothing left to do
272
+ # (verified by tests re-running reconcile end-to-end).
273
+ if saw_unknown_owner:
274
+ return envelope(
275
+ "unknown_owner", runtime_id=last_runtime_id, can_proceed=False,
276
+ cleanup=cleanup,
277
+ error_code=RuntimeErrorCode.RUNTIME_OWNER_UNKNOWN,
278
+ technical_detail="unowned/unverifiable runtime present; nothing deleted for it",
279
+ )
280
+ if failure is not None:
281
+ # Cleanup attempted but incomplete: the leftovers are still there —
282
+ # report the stale state honestly with can_proceed=False rather
283
+ # than pretending any classification resolved cleanly.
284
+ return envelope(
285
+ "stale_ephemeral" if cleanup["attempted"] else "clean",
286
+ runtime_id=last_runtime_id, can_proceed=False,
287
+ cleanup=cleanup,
288
+ error_code=RuntimeErrorCode.RUNTIME_RECONCILE_FAILED,
289
+ technical_detail=failure,
290
+ )
291
+ if saw_recycled:
292
+ classification = "stale_ephemeral"
293
+ elif saw_registry_prune:
294
+ classification = "stale_registry"
295
+ else:
296
+ classification = "clean"
297
+ return envelope(
298
+ classification, runtime_id=last_runtime_id, can_proceed=True,
299
+ cleanup=cleanup,
300
+ )
301
+
302
+
303
+ def row_owner(row: Optional[Dict[str, str]]) -> str:
304
+ return (row or {}).get("owner", "")
305
+
306
+
307
+ def docker_state(name: str, executor: Any, docker_by_name: Dict[str, Dict[str, str]]) -> str:
308
+ """Docker-side state for *name*: prefer the label query row; fall back
309
+ to a direct inspect (also distinguishes not_found)."""
310
+ if name in docker_by_name:
311
+ return _status_to_state(docker_by_name[name]["status"])
312
+ from aisc.application.runtime import _get_container_state
313
+
314
+ return _get_container_state(name, executor) or "unknown"
315
+
316
+
317
+ def _no_cleanup() -> Dict[str, Any]:
318
+ return {"attempted": False, "stopped": False, "removed": False, "registry_pruned": False}
aisc/applog.py ADDED
@@ -0,0 +1,103 @@
1
+ """Lifecycle event log (P1, lifecycle-logging round) — the CLI-side appender.
2
+
3
+ One JSONL event per line in ``<shared data root>/logs/aisc.log``, appended by
4
+ BOTH the Python CLI (``source: "cli"``) and the Workbench's Rust side
5
+ (``source: "app"``) — a single timeline a ``run_id`` threads end to end
6
+ (the Workbench injects ``AISC_RUN_ID`` into child env; the envelope reuses it).
7
+
8
+ Red line (allowed-fields-only, mirrors the diagnostic bundle's D6-05/06
9
+ philosophy): stdin payloads, subscription URLs, API keys, PTY content, full
10
+ environments and absolute workspace paths NEVER enter the log. The schema is
11
+ fixed-key by construction — redaction by design, not by filtering.
12
+
13
+ Failure policy: logging must never break a command — every write is
14
+ best-effort; resolver/IO errors are swallowed (the in-memory op path and the
15
+ stdout envelope remain authoritative).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Dict, Optional
25
+
26
+ #: Rotation bounds — byte sizes kept identical to the Rust side
27
+ #: (workbench/src-tauri/src/logging.rs) so either appender rotates the file.
28
+ MAX_BYTES = 2 * 1024 * 1024
29
+ KEEP_ROUNATED = 2 # aisc.log + .1 + .2
30
+
31
+ LOG_DIR_NAME = "logs"
32
+ LOG_FILE_NAME = "aisc.log"
33
+
34
+
35
+ def _utc_now() -> str:
36
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
37
+
38
+
39
+ def log_file_path(*, env=None) -> Optional[Path]:
40
+ """Resolve the shared log file path; None when the data root can't be
41
+ resolved (fail-open — callers then skip logging)."""
42
+ try:
43
+ from aisc.application.data_root import shared_root
44
+
45
+ root = shared_root(env if env is not None else os.environ)
46
+ except Exception:
47
+ return None
48
+ return root / LOG_DIR_NAME / LOG_FILE_NAME
49
+
50
+
51
+ def _rotate(path: Path) -> None:
52
+ """Size-capped rotation: aisc.log -> aisc.log.1 -> aisc.log.2 (oldest
53
+ dropped)."""
54
+ try:
55
+ if not path.exists() or path.stat().st_size < MAX_BYTES:
56
+ return
57
+ for i in range(KEEP_ROUNATED - 1, 0, -1):
58
+ src = path.with_name(f"{path.name}.{i}")
59
+ dst = path.with_name(f"{path.name}.{i + 1}")
60
+ if src.exists():
61
+ src.replace(dst)
62
+ path.replace(path.with_name(f"{path.name}.1"))
63
+ except OSError:
64
+ pass # rotation failure must never block the append
65
+
66
+
67
+ def append_event(
68
+ event: str,
69
+ *,
70
+ level: str = "info",
71
+ source: str = "cli",
72
+ run_id: Optional[str] = None,
73
+ detail: Optional[str] = None,
74
+ env=None,
75
+ **fields: Any,
76
+ ) -> None:
77
+ """Append one lifecycle event; never raises.
78
+
79
+ ``fields`` are extra fixed-schema keys (``command``, ``exit_code``,
80
+ ``duration_ms``, ``error_code`` …) — callers pass only allowlisted,
81
+ secret-free values.
82
+ """
83
+ path = log_file_path(env=env if env is not None else os.environ)
84
+ if path is None:
85
+ return
86
+ record: Dict[str, Any] = {
87
+ "ts": _utc_now(),
88
+ "level": level,
89
+ "source": source,
90
+ "event": event,
91
+ }
92
+ if run_id:
93
+ record["run_id"] = run_id
94
+ if detail:
95
+ record["detail"] = detail
96
+ record.update(fields)
97
+ try:
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ _rotate(path)
100
+ with open(path, "a", encoding="utf-8") as fh:
101
+ fh.write(json.dumps(record, ensure_ascii=True, separators=(",", ":")) + "\n")
102
+ except Exception:
103
+ return # best-effort by contract
aisc/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI package — argument parsing and command dispatch."""
@@ -0,0 +1,217 @@
1
+ """v2.1.7 S4 (Gate-S4): docker build output → structured build.progress.
2
+
3
+ Pure, stateful parser fed with the raw docker stdout/stderr chunks (the same
4
+ bytes that ride ``build.output``). Emits at most one ``ProgressUpdate`` per
5
+ recognised line; unrecognised lines are ignored — the raw log is the only
6
+ place they matter (honest-progress rule: never fabricate a step).
7
+
8
+ Two output formats are recognised (the docker argv deliberately stays
9
+ untouched — forcing ``--progress=plain`` would break the legacy builder):
10
+
11
+ * BuildKit plain (piped stdout uses it automatically):
12
+ ``#12 [3/7] RUN apt-get update`` / ``#12 CACHED`` / ``#12 DONE ...`` /
13
+ ``#12 exporting to image`` / ``naming to docker.io/super-claude:latest``.
14
+ * Legacy builder: ``Step 3/12 : RUN ...`` / ``Successfully built <id>``.
15
+
16
+ Honesty invariants enforced here (Gate-S4 §1):
17
+ * ``percent`` only when a step mapping is known; monotonic, never lower;
18
+ * never reaches 100 (capped at 99.9) — only build.complete means done;
19
+ * pull work is its own indeterminate phase (no fake byte percent).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import dataclass
26
+ from typing import Optional
27
+
28
+ # BuildKit plain: "#12 [3/7] RUN apt-get update" (inner stage step) or
29
+ # "#12 exporting to image" (phase text without [i/t]).
30
+ _BUILDKIT_STEP = re.compile(r"^#\d+ \[(\d+)/(\d+)\] (.*)$")
31
+ _BUILDKIT_PHASE = re.compile(r"^#\d+ (.+)$")
32
+
33
+ # "transferring context: 247.12MB 5.2s" / "... 1.2GB done" — cumulative
34
+ # bytes WITHOUT a total (BuildKit never announces the context size), so the
35
+ # prepare bar can only be determinate against an ESTIMATED denominator.
36
+ _CONTEXT_BYTES = re.compile(
37
+ r"^transferring context: ([\d.]+)\s*(B|kB|MB|GB|TB)\b", re.I
38
+ )
39
+ _UNIT_MULT = {"b": 1.0, "kb": 1e3, "mb": 1e6, "gb": 1e9, "tb": 1e12}
40
+
41
+
42
+ def _fmt_mb(n: float) -> str:
43
+ return f"{n / 1e6:.1f}MB"
44
+
45
+
46
+ def parse_context_bytes(line: str) -> Optional[float]:
47
+ """Cumulative transferred bytes from a `transferring context:` line."""
48
+ m = _CONTEXT_BYTES.match(line)
49
+ if not m:
50
+ return None
51
+ return float(m.group(1)) * _UNIT_MULT[m.group(2).lower()]
52
+ # Legacy builder: "Step 3/12 : RUN ..." / bare "Step 3 : ..."
53
+ _LEGACY_STEP = re.compile(r"^Step (\d+)(?:/(\d+))? : (.*)$")
54
+
55
+ _PHASE_PATTERNS = (
56
+ (re.compile(r"^(?:resolve|pulling|sha256:|docker\.io|Downloading|extracting|Pulling)", re.I), "pull"),
57
+ (re.compile(r"^(?:transferring context|load build definition|load \.dockerignore|load metadata)", re.I), "prepare"),
58
+ (re.compile(r"^exporting (?:to image|layers|manifest)", re.I), "export"),
59
+ (re.compile(r"^(?:naming to|writing image|loaded image|tagged)", re.I), "export"),
60
+ (re.compile(r"^Successfully (?:built|tagged)", re.I), "done"),
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class ProgressUpdate:
66
+ """One build.progress payload (Gate-S4 §1 field contract)."""
67
+
68
+ phase: str
69
+ step_current: Optional[int]
70
+ step_total: Optional[int]
71
+ percent: Optional[float]
72
+ progress_kind: str # determinate | indeterminate
73
+ summary: str
74
+
75
+
76
+ class BuildProgressParser:
77
+ """Feed raw chunks; collect :class:`ProgressUpdate` for each recognised
78
+ line. Monotonic by construction: percent only ever rises."""
79
+
80
+ def __init__(self, context_total_bytes: Optional[float] = None) -> None:
81
+ self._step_current = 0
82
+ self._step_total: Optional[int] = None
83
+ self._last_percent = 0.0
84
+ self._phase = "prepare"
85
+ # Estimated context denominator from the PREVIOUS build's log
86
+ # (Gate-S4 §1 amendment): enables a determinate prepare bar that is
87
+ # explicitly marked ≈ in the summary and capped at 95.
88
+ self._context_total_bytes = context_total_bytes
89
+
90
+ # -- helpers -----------------------------------------------------------
91
+
92
+ def _percent(self, current: int, total: Optional[int]) -> Optional[float]:
93
+ if not total or total <= 0 or current < 1:
94
+ return None
95
+ raw = min(current / total, 1.0) * 100.0
96
+ # Never 100 before build.complete, never regress.
97
+ raw = min(raw, 99.9)
98
+ if raw < self._last_percent:
99
+ raw = self._last_percent
100
+ self._last_percent = raw
101
+ return round(raw, 1)
102
+
103
+ def _update(
104
+ self,
105
+ phase: str,
106
+ summary: str,
107
+ *,
108
+ step_current: Optional[int] = None,
109
+ step_total: Optional[int] = None,
110
+ ) -> ProgressUpdate:
111
+ self._phase = phase
112
+ cur = step_current if step_current is not None else self._step_current
113
+ tot = step_total if step_total is not None else self._step_total
114
+ if step_total is not None and (self._step_total is None or step_total > self._step_total):
115
+ self._step_total = step_total
116
+ if cur is not None and cur >= 0:
117
+ self._step_current = cur
118
+ percent = self._percent(cur, tot) if phase == "steps" else None
119
+ return ProgressUpdate(
120
+ phase=phase,
121
+ step_current=cur if phase == "steps" else None,
122
+ step_total=tot if phase == "steps" else None,
123
+ percent=percent,
124
+ progress_kind="determinate" if percent is not None else "indeterminate",
125
+ summary=summary,
126
+ )
127
+
128
+ def _context_update(self, line: str) -> ProgressUpdate:
129
+ """`transferring context` — cumulative bytes only. With an estimated
130
+ denominator from the previous build, emit a determinate ≈ percent
131
+ (capped at 95 — the real total is unknown); otherwise stay
132
+ indeterminate with the live byte count as the summary."""
133
+ self._phase = "prepare"
134
+ cur = parse_context_bytes(line)
135
+ total = self._context_total_bytes
136
+ if cur is not None and total and total > 0:
137
+ raw = min(cur / total, 0.95) * 100.0
138
+ if raw < self._last_percent:
139
+ raw = self._last_percent
140
+ self._last_percent = raw
141
+ return ProgressUpdate(
142
+ phase="prepare",
143
+ step_current=None,
144
+ step_total=int(total),
145
+ percent=round(raw, 1),
146
+ progress_kind="determinate",
147
+ summary=f"≈ {_fmt_mb(cur)} / ≈ {_fmt_mb(total)}",
148
+ )
149
+ return ProgressUpdate(
150
+ phase="prepare",
151
+ step_current=None,
152
+ step_total=None,
153
+ percent=None,
154
+ progress_kind="indeterminate",
155
+ summary=line,
156
+ )
157
+
158
+ # -- api ---------------------------------------------------------------
159
+
160
+ @property
161
+ def phase(self) -> str:
162
+ return self._phase
163
+
164
+ def feed(self, chunk: str) -> list[ProgressUpdate]:
165
+ """Parse one raw chunk; return the updates for its recognised lines."""
166
+ updates: list[ProgressUpdate] = []
167
+ for line in chunk.splitlines():
168
+ stripped = line.strip()
169
+ if not stripped:
170
+ continue
171
+ upd = self._parse_line(stripped)
172
+ if upd is not None:
173
+ updates.append(upd)
174
+ return updates
175
+
176
+ def _parse_line(self, line: str) -> Optional[ProgressUpdate]:
177
+ m = _BUILDKIT_STEP.match(line)
178
+ if m:
179
+ cur, tot, desc = int(m.group(1)), int(m.group(2)), m.group(3)
180
+ return self._update("steps", desc, step_current=cur, step_total=tot)
181
+
182
+ m = _LEGACY_STEP.match(line)
183
+ if m:
184
+ cur = int(m.group(1))
185
+ tot = int(m.group(2)) if m.group(2) else None
186
+ return self._update("steps", m.group(3), step_current=cur, step_total=tot)
187
+
188
+ for pattern, phase in _PHASE_PATTERNS:
189
+ if pattern.match(line):
190
+ if phase == "prepare":
191
+ return self._context_update(line)
192
+ return self._update(phase, line)
193
+
194
+ # BuildKit bare phase lines ("#12 exporting to image", "#7 DONE").
195
+ m = _BUILDKIT_PHASE.match(line)
196
+ if m:
197
+ text = m.group(1).strip()
198
+ if text.lower() == "done":
199
+ # completion of a single step — not the build's end
200
+ return None
201
+ # 2026-08-27 manual test (full-cache build): a CACHED step carries
202
+ # no [i/t] header, so a fully-cached build otherwise shows ZERO
203
+ # motion. Surface it as an indeterminate steps-phase update — no
204
+ # fabricated step number, percent stays honest (null).
205
+ if text.lower() == "cached":
206
+ return ProgressUpdate(
207
+ phase="steps",
208
+ step_current=None,
209
+ step_total=self._step_total,
210
+ percent=None,
211
+ progress_kind="indeterminate",
212
+ summary="CACHED",
213
+ )
214
+ mapped = next((p for pat, p in _PHASE_PATTERNS if pat.match(text)), None)
215
+ if mapped:
216
+ return self._update(mapped, text)
217
+ return None
@@ -0,0 +1 @@
1
+ """CLI command implementations."""