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,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."""
|