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,265 @@
|
|
|
1
|
+
"""Agent Artifact application service — session-scoped registry (Stage 3).
|
|
2
|
+
|
|
3
|
+
Registry lives OUTSIDE the workspace (host data dir) so ``git status`` never
|
|
4
|
+
gains entries (A-ART04-1, R3-05). Storage is one JSONL per session keyed by
|
|
5
|
+
``<data-root>/aisc-artifacts/<workspace-hash>/<session-id>.jsonl``.
|
|
6
|
+
|
|
7
|
+
Concurrency / corruption (A-ART06-1, R3-07):
|
|
8
|
+
- append uses a lock file (best-effort, 10s timeout) + temp file + ``os.replace``;
|
|
9
|
+
- a corrupt line is isolated (skipped + reported), never truncates the file;
|
|
10
|
+
- records are idempotent by ``artifact_id``: re-recording the same id updates
|
|
11
|
+
in place (modified/deleted/renamed) instead of duplicating.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
22
|
+
|
|
23
|
+
from aisc.domain.artifacts import (
|
|
24
|
+
ARTIFACT_SCHEMA_VERSION,
|
|
25
|
+
ArtifactAction,
|
|
26
|
+
ArtifactProvenance,
|
|
27
|
+
ArtifactRecord,
|
|
28
|
+
validate_relative_path,
|
|
29
|
+
workspace_hash,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Lock scope: one per process (the CLI is single-user, single-process per
|
|
33
|
+
# command; the Workbench holds its own). Cross-process safety is provided by
|
|
34
|
+
# the lock file at write time.
|
|
35
|
+
_LOCKS: Dict[str, threading.Lock] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def data_root() -> Path:
|
|
39
|
+
"""Host data dir for the artifact registry (never inside a workspace).
|
|
40
|
+
|
|
41
|
+
Stage 7 (DATA-04): canonical location is ``<data-root>/artifacts``
|
|
42
|
+
(``AISC_DATA_ROOT`` override applies). The explicit
|
|
43
|
+
``AISC_ARTIFACT_DATA_ROOT`` override keeps working for tests/dev.
|
|
44
|
+
"""
|
|
45
|
+
env = os.environ.get("AISC_ARTIFACT_DATA_ROOT")
|
|
46
|
+
if env:
|
|
47
|
+
return Path(env)
|
|
48
|
+
from aisc.application.data_root import shared_root
|
|
49
|
+
|
|
50
|
+
return shared_root() / "artifacts"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _legacy_data_root() -> Path:
|
|
54
|
+
"""Pre-Stage-7 artifact root (read fallback for old records)."""
|
|
55
|
+
if os.name == "nt":
|
|
56
|
+
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local")
|
|
57
|
+
return Path(base) / "aisc" / "artifacts"
|
|
58
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
59
|
+
if xdg:
|
|
60
|
+
return Path(xdg) / "aisc" / "artifacts"
|
|
61
|
+
return Path.home() / ".local" / "share" / "aisc" / "artifacts"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def registry_path(workspace: Path, session_id: str) -> Path:
|
|
65
|
+
"""JSONL path for one session's artifact registry."""
|
|
66
|
+
return data_root() / workspace_hash(workspace) / f"{session_id}.jsonl"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _session_lock(workspace: Path, session_id: str) -> threading.Lock:
|
|
70
|
+
key = f"{workspace_hash(workspace)}/{session_id}"
|
|
71
|
+
lock = _LOCKS.get(key)
|
|
72
|
+
if lock is None:
|
|
73
|
+
lock = threading.Lock()
|
|
74
|
+
_LOCKS[key] = lock
|
|
75
|
+
return lock
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _lock_file_path(workspace: Path, session_id: str) -> Path:
|
|
79
|
+
return registry_path(workspace, session_id).with_suffix(".lock")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _acquire_lock(workspace: Path, session_id: str, timeout: float = 10.0) -> None:
|
|
83
|
+
"""Best-effort cross-process lock; raises TimeoutError on timeout."""
|
|
84
|
+
lock_path = _lock_file_path(workspace, session_id)
|
|
85
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
import time
|
|
87
|
+
|
|
88
|
+
deadline = time.monotonic() + timeout
|
|
89
|
+
while True:
|
|
90
|
+
try:
|
|
91
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
92
|
+
os.close(fd)
|
|
93
|
+
return
|
|
94
|
+
except FileExistsError:
|
|
95
|
+
# Stale lock detection: older than 30s is assumed abandoned.
|
|
96
|
+
try:
|
|
97
|
+
if time.time() - lock_path.stat().st_mtime > 30:
|
|
98
|
+
lock_path.unlink(missing_ok=True)
|
|
99
|
+
continue
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
if time.monotonic() >= deadline:
|
|
103
|
+
raise TimeoutError(
|
|
104
|
+
f"artifact registry lock timeout: {lock_path}"
|
|
105
|
+
)
|
|
106
|
+
time.sleep(0.05)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _release_lock(workspace: Path, session_id: str) -> None:
|
|
110
|
+
_lock_file_path(workspace, session_id).unlink(missing_ok=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_lines(path: Path) -> List[ArtifactRecord]:
|
|
114
|
+
"""Read all records, isolating corrupt lines (never truncate, R3-07)."""
|
|
115
|
+
if not path.is_file():
|
|
116
|
+
return []
|
|
117
|
+
out: List[ArtifactRecord] = []
|
|
118
|
+
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
119
|
+
if not line.strip():
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
out.append(ArtifactRecord.from_dict(json.loads(line)))
|
|
123
|
+
except (json.JSONDecodeError, ValueError, KeyError):
|
|
124
|
+
# Corrupt/unsupported line: isolate, keep the rest (A-ART01-2).
|
|
125
|
+
continue
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _write_lines(path: Path, records: List[ArtifactRecord]) -> None:
|
|
130
|
+
"""Atomic replace of the JSONL file (temp + os.replace)."""
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
fd, tmp = tempfile.mkstemp(prefix=".artifacts_", dir=str(path.parent), text=True)
|
|
133
|
+
try:
|
|
134
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
135
|
+
for rec in records:
|
|
136
|
+
f.write(json.dumps(rec.to_dict(), ensure_ascii=True) + "\n")
|
|
137
|
+
f.flush()
|
|
138
|
+
os.fsync(f.fileno())
|
|
139
|
+
os.replace(tmp, str(path))
|
|
140
|
+
except Exception:
|
|
141
|
+
try:
|
|
142
|
+
os.unlink(tmp)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Public operations
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def record(
|
|
153
|
+
workspace: Path,
|
|
154
|
+
record_data: ArtifactRecord,
|
|
155
|
+
*,
|
|
156
|
+
session_id: Optional[str] = None,
|
|
157
|
+
) -> ArtifactRecord:
|
|
158
|
+
"""Validate + persist one artifact record. Idempotent by artifact_id.
|
|
159
|
+
|
|
160
|
+
``session_id`` defaults to the record's producer.session_id. Returns the
|
|
161
|
+
validated record.
|
|
162
|
+
"""
|
|
163
|
+
rec = record_data.validate()
|
|
164
|
+
session = session_id or rec.producer.get("session_id")
|
|
165
|
+
if not session:
|
|
166
|
+
raise ValueError("session_id is required")
|
|
167
|
+
path = registry_path(workspace, session)
|
|
168
|
+
lock = _session_lock(workspace, session)
|
|
169
|
+
with lock:
|
|
170
|
+
_acquire_lock(workspace, session)
|
|
171
|
+
try:
|
|
172
|
+
records = _read_lines(path)
|
|
173
|
+
# Idempotent: replace an existing record with the same id.
|
|
174
|
+
kept = [r for r in records if r.artifact_id != rec.artifact_id]
|
|
175
|
+
kept.append(rec)
|
|
176
|
+
_write_lines(path, kept)
|
|
177
|
+
finally:
|
|
178
|
+
_release_lock(workspace, session)
|
|
179
|
+
return rec
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def list_records(
|
|
183
|
+
workspace: Path,
|
|
184
|
+
*,
|
|
185
|
+
session_id: Optional[str] = None,
|
|
186
|
+
kind: Optional[str] = None,
|
|
187
|
+
) -> List[ArtifactRecord]:
|
|
188
|
+
"""List records for the session (or all sessions under the workspace hash).
|
|
189
|
+
|
|
190
|
+
Stage 7: the canonical registry lives under ``<data-root>/artifacts``;
|
|
191
|
+
session files that exist ONLY in the pre-Stage-7 root are still read
|
|
192
|
+
(transition fallback — new files always win by session id).
|
|
193
|
+
"""
|
|
194
|
+
ws_hash = workspace_hash(workspace)
|
|
195
|
+
base = data_root() / ws_hash
|
|
196
|
+
files = [base / f"{session_id}.jsonl"] if session_id else sorted(base.glob("*.jsonl"))
|
|
197
|
+
files = [f for f in files if f.is_file() and not f.name.endswith(".lock")]
|
|
198
|
+
if not files:
|
|
199
|
+
# No canonical files: fall back to the legacy root's session files.
|
|
200
|
+
legacy_base = _legacy_data_root() / ws_hash
|
|
201
|
+
if legacy_base.is_dir():
|
|
202
|
+
legacy_files = (
|
|
203
|
+
[legacy_base / f"{session_id}.jsonl"]
|
|
204
|
+
if session_id
|
|
205
|
+
else sorted(legacy_base.glob("*.jsonl"))
|
|
206
|
+
)
|
|
207
|
+
files = [f for f in legacy_files if f.is_file() and not f.name.endswith(".lock")]
|
|
208
|
+
out: List[ArtifactRecord] = []
|
|
209
|
+
for f in files:
|
|
210
|
+
for rec in _read_lines(f):
|
|
211
|
+
if kind and rec.kind != kind:
|
|
212
|
+
continue
|
|
213
|
+
out.append(rec)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def inspect_record(workspace: Path, artifact_id: str) -> Optional[ArtifactRecord]:
|
|
218
|
+
"""Find a record by artifact_id across sessions for this workspace."""
|
|
219
|
+
for rec in list_records(workspace):
|
|
220
|
+
if rec.artifact_id == artifact_id:
|
|
221
|
+
return rec
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def clear_session(workspace: Path, session_id: str) -> None:
|
|
226
|
+
"""Remove one session's registry file (returns silently if absent)."""
|
|
227
|
+
path = registry_path(workspace, session_id)
|
|
228
|
+
lock = _session_lock(workspace, session_id)
|
|
229
|
+
with lock:
|
|
230
|
+
_acquire_lock(workspace, session_id)
|
|
231
|
+
try:
|
|
232
|
+
path.unlink(missing_ok=True)
|
|
233
|
+
finally:
|
|
234
|
+
_release_lock(workspace, session_id)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def mark_missing(
|
|
238
|
+
workspace: Path,
|
|
239
|
+
artifact_id: str,
|
|
240
|
+
*,
|
|
241
|
+
state: str = "missing",
|
|
242
|
+
) -> Optional[ArtifactRecord]:
|
|
243
|
+
"""Flip a present record to missing/deleted without changing provenance."""
|
|
244
|
+
from aisc.domain.artifacts import ArtifactState
|
|
245
|
+
|
|
246
|
+
for rec in list_records(workspace):
|
|
247
|
+
if rec.artifact_id == artifact_id:
|
|
248
|
+
updated = ArtifactRecord(
|
|
249
|
+
schema_version=rec.schema_version,
|
|
250
|
+
artifact_id=rec.artifact_id,
|
|
251
|
+
workspace_relative_path=rec.workspace_relative_path,
|
|
252
|
+
action=rec.action,
|
|
253
|
+
kind=rec.kind,
|
|
254
|
+
media_type=rec.media_type,
|
|
255
|
+
label=rec.label,
|
|
256
|
+
open_with=rec.open_with,
|
|
257
|
+
producer=dict(rec.producer),
|
|
258
|
+
state=state,
|
|
259
|
+
provenance=rec.provenance,
|
|
260
|
+
recorded_at=rec.recorded_at,
|
|
261
|
+
previous_path=rec.previous_path,
|
|
262
|
+
extra=dict(rec.extra),
|
|
263
|
+
).validate()
|
|
264
|
+
return record(workspace, updated, session_id=rec.producer.get("session_id"))
|
|
265
|
+
return None
|