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