@foggy-projects/deepseek-harness-plugin 0.4.0-beta.2

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 (34) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +34 -0
  3. package/cordis.patch.yml +6 -0
  4. package/experience/linux/README.md +54 -0
  5. package/experience/linux/prepare.sh +190 -0
  6. package/lib/client.js +254 -0
  7. package/lib/index.js +259 -0
  8. package/lib/remote-descriptor.js +29 -0
  9. package/lib/remote.js +8 -0
  10. package/lib/typert.js +9 -0
  11. package/lib/version.js +15 -0
  12. package/package.json +59 -0
  13. package/skills/foggy-deepseek-onboarding/SKILL.md +83 -0
  14. package/skills/foggy-deepseek-onboarding/assets/connection.schema.json +50 -0
  15. package/skills/foggy-deepseek-onboarding/assets/datasource.example.json +13 -0
  16. package/skills/foggy-deepseek-onboarding/assets/env.example +14 -0
  17. package/skills/foggy-deepseek-onboarding/assets/onboarding-state.schema.json +22 -0
  18. package/skills/foggy-deepseek-onboarding/assets/semantic-plan.example.json +8 -0
  19. package/skills/foggy-deepseek-onboarding/assets/semantic-plan.schema.json +21 -0
  20. package/skills/foggy-deepseek-onboarding/assets/versions.json +93 -0
  21. package/skills/foggy-deepseek-onboarding/references/onboarding-workflow.md +120 -0
  22. package/skills/foggy-deepseek-onboarding/scripts/doctor.ps1 +4 -0
  23. package/skills/foggy-deepseek-onboarding/scripts/doctor.sh +5 -0
  24. package/skills/foggy-deepseek-onboarding/scripts/install.ps1 +4 -0
  25. package/skills/foggy-deepseek-onboarding/scripts/install.sh +5 -0
  26. package/skills/foggy-deepseek-onboarding/scripts/onboard.ps1 +4 -0
  27. package/skills/foggy-deepseek-onboarding/scripts/onboard.sh +5 -0
  28. package/skills/foggy-deepseek-onboarding/scripts/onboarding.py +2113 -0
  29. package/skills/foggy-deepseek-onboarding/scripts/runtime-start.ps1 +4 -0
  30. package/skills/foggy-deepseek-onboarding/scripts/runtime-start.sh +5 -0
  31. package/skills/foggy-deepseek-onboarding/scripts/runtime-stop.ps1 +4 -0
  32. package/skills/foggy-deepseek-onboarding/scripts/runtime-stop.sh +5 -0
  33. package/skills/foggy-deepseek-onboarding/scripts/uninstall.ps1 +4 -0
  34. package/skills/foggy-deepseek-onboarding/scripts/uninstall.sh +5 -0
@@ -0,0 +1,2113 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic installer and local Runtime lifecycle for Foggy DSH onboarding."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from pathlib import Path, PurePosixPath
12
+ import re
13
+ import shutil
14
+ import signal
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import urllib.request
21
+ import venv
22
+ import zipfile
23
+
24
+
25
+ STATE_SCHEMA = "foggy-deepseek-onboarding-install/v1"
26
+ RUNTIME_STATE_SCHEMA = "foggy-deepseek-onboarding-runtime/v1"
27
+ ONBOARDING_STATE_SCHEMA = "foggy-deepseek-onboarding-state/v1"
28
+ CONNECTION_SCHEMA = "foggy-deepseek-connection/v1"
29
+ SEMANTIC_PLAN_SCHEMA = "foggy-deepseek-semantic-plan/v1"
30
+ PROFILE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
31
+ ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
32
+ OPAQUE_PROFILE_PATTERN = re.compile(r"^fop_[a-f0-9]{32}$")
33
+ OPAQUE_REVISION_PATTERN = re.compile(r"^sha256:[a-f0-9]{64}$")
34
+ MODEL_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
35
+
36
+
37
+ class OnboardingError(RuntimeError):
38
+ pass
39
+
40
+
41
+ def now_utc() -> str:
42
+ return dt.datetime.now(dt.timezone.utc).isoformat()
43
+
44
+
45
+ def emit(payload: dict, exit_code: int = 0) -> None:
46
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
47
+ raise SystemExit(exit_code)
48
+
49
+
50
+ def skill_root() -> Path:
51
+ return Path(__file__).resolve().parent.parent
52
+
53
+
54
+ def versions_path() -> Path:
55
+ return skill_root() / "assets" / "versions.json"
56
+
57
+
58
+ def load_versions() -> dict:
59
+ data = json.loads(versions_path().read_text(encoding="utf-8"))
60
+ if data.get("schemaVersion") != "foggy-deepseek-onboarding-versions/v1":
61
+ raise OnboardingError("Unexpected versions.json schema")
62
+ return data
63
+
64
+
65
+ def default_install_root() -> Path:
66
+ if os.name == "nt":
67
+ base = os.environ.get("LOCALAPPDATA")
68
+ if not base:
69
+ raise OnboardingError("LOCALAPPDATA is not set")
70
+ return Path(base) / "Foggy" / "DeepSeekHarness"
71
+ base = os.environ.get("XDG_DATA_HOME")
72
+ return Path(base) / "foggy" / "deepseek-harness" if base else Path.home() / ".local" / "share" / "foggy" / "deepseek-harness"
73
+
74
+
75
+ def default_data_root() -> Path:
76
+ if os.name == "nt":
77
+ base = os.environ.get("LOCALAPPDATA")
78
+ if not base:
79
+ raise OnboardingError("LOCALAPPDATA is not set")
80
+ return Path(base) / "Foggy" / "DeepSeekHarnessData"
81
+ base = os.environ.get("XDG_STATE_HOME")
82
+ return Path(base) / "foggy" / "deepseek-harness" if base else Path.home() / ".local" / "state" / "foggy" / "deepseek-harness"
83
+
84
+
85
+ def normalized(path: str | Path) -> Path:
86
+ return Path(path).expanduser().resolve(strict=False)
87
+
88
+
89
+ def is_child(path: Path, parent: Path) -> bool:
90
+ try:
91
+ path.resolve(strict=False).relative_to(parent.resolve(strict=False))
92
+ return True
93
+ except ValueError:
94
+ return False
95
+
96
+
97
+ def assert_managed_root(path: Path, label: str) -> None:
98
+ resolved = path.resolve(strict=False)
99
+ anchor = Path(resolved.anchor).resolve(strict=False)
100
+ if resolved in {Path.home().resolve(strict=False), anchor}:
101
+ raise OnboardingError(f"{label} cannot be a home or filesystem root: {resolved}")
102
+ if path.exists() and path.is_symlink():
103
+ raise OnboardingError(f"{label} cannot be a symlink: {path}")
104
+
105
+
106
+ def version_tuple(text: str) -> tuple[int, ...]:
107
+ match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text)
108
+ if not match:
109
+ return ()
110
+ return tuple(int(item or 0) for item in match.groups())
111
+
112
+
113
+ def command_result(command: list[str], timeout: int = 30, check: bool = False) -> dict:
114
+ started = time.monotonic()
115
+ try:
116
+ result = subprocess.run(
117
+ command,
118
+ capture_output=True,
119
+ text=True,
120
+ encoding="utf-8",
121
+ errors="replace",
122
+ timeout=timeout,
123
+ check=False,
124
+ )
125
+ payload = {
126
+ "command": command[0],
127
+ "available": True,
128
+ "exitCode": result.returncode,
129
+ "stdout": result.stdout.strip(),
130
+ "stderr": result.stderr.strip(),
131
+ "durationMs": round((time.monotonic() - started) * 1000),
132
+ }
133
+ except FileNotFoundError:
134
+ payload = {
135
+ "command": command[0],
136
+ "available": False,
137
+ "exitCode": None,
138
+ "stdout": "",
139
+ "stderr": "command not found",
140
+ "durationMs": round((time.monotonic() - started) * 1000),
141
+ }
142
+ except subprocess.TimeoutExpired:
143
+ payload = {
144
+ "command": command[0],
145
+ "available": True,
146
+ "exitCode": None,
147
+ "stdout": "",
148
+ "stderr": f"timed out after {timeout} seconds",
149
+ "durationMs": round((time.monotonic() - started) * 1000),
150
+ }
151
+ if check and (not payload["available"] or payload["exitCode"] != 0):
152
+ raise OnboardingError(f"Command failed: {command[0]}: {payload['stderr'] or payload['stdout']}")
153
+ return payload
154
+
155
+
156
+ def sha256(path: Path) -> str:
157
+ digest = hashlib.sha256()
158
+ with path.open("rb") as stream:
159
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
160
+ digest.update(chunk)
161
+ return digest.hexdigest()
162
+
163
+
164
+ def verify_asset(path: Path, expected: str) -> None:
165
+ actual = sha256(path)
166
+ if actual.lower() != expected.lower():
167
+ raise OnboardingError(f"SHA256 mismatch for {path.name}: expected={expected} actual={actual}")
168
+
169
+
170
+ def cached_asset(name: str, expected: str, cache_dirs: list[Path]) -> Path | None:
171
+ for cache_dir in cache_dirs:
172
+ direct = cache_dir / name
173
+ candidates = [direct] if direct.is_file() else []
174
+ if not candidates and cache_dir.is_dir():
175
+ candidates = list(cache_dir.rglob(name))
176
+ for candidate in candidates:
177
+ try:
178
+ verify_asset(candidate, expected)
179
+ return candidate
180
+ except OnboardingError:
181
+ continue
182
+ return None
183
+
184
+
185
+ def materialize(asset: dict, destination: Path, cache_dirs: list[Path]) -> dict:
186
+ destination.parent.mkdir(parents=True, exist_ok=True)
187
+ if destination.is_file():
188
+ verify_asset(destination, asset["sha256"])
189
+ return {"file": asset["file"], "path": str(destination), "source": "existing", "sha256": asset["sha256"]}
190
+ cached = cached_asset(asset["file"], asset["sha256"], cache_dirs)
191
+ if cached:
192
+ shutil.copy2(cached, destination)
193
+ source = "cache"
194
+ else:
195
+ temporary = destination.with_name(destination.name + ".download")
196
+ if temporary.exists():
197
+ temporary.unlink()
198
+ try:
199
+ urllib.request.urlretrieve(asset["url"], temporary)
200
+ verify_asset(temporary, asset["sha256"])
201
+ os.replace(temporary, destination)
202
+ finally:
203
+ if temporary.exists():
204
+ temporary.unlink()
205
+ source = "network"
206
+ verify_asset(destination, asset["sha256"])
207
+ return {"file": asset["file"], "path": str(destination), "source": source, "sha256": asset["sha256"]}
208
+
209
+
210
+ def venv_python(install_root: Path) -> Path:
211
+ return install_root / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
212
+
213
+
214
+ def venv_cli(install_root: Path) -> Path:
215
+ return install_root / "venv" / ("Scripts/foggy-runtime.exe" if os.name == "nt" else "bin/foggy-runtime")
216
+
217
+
218
+ def atomic_json(path: Path, payload: dict) -> None:
219
+ path.parent.mkdir(parents=True, exist_ok=True)
220
+ temporary = path.with_name(path.name + ".tmp")
221
+ temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
222
+ os.replace(temporary, path)
223
+
224
+
225
+ def read_json_object(path: Path, label: str) -> dict:
226
+ if not path.is_file():
227
+ raise OnboardingError(f"{label} not found: {path}")
228
+ try:
229
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
230
+ except (OSError, json.JSONDecodeError) as exc:
231
+ raise OnboardingError(f"{label} is not valid JSON: {path}") from exc
232
+ if not isinstance(payload, dict):
233
+ raise OnboardingError(f"{label} must contain one JSON object: {path}")
234
+ return payload
235
+
236
+
237
+ def safe_profile(value: str) -> str:
238
+ if not PROFILE_PATTERN.fullmatch(value):
239
+ raise OnboardingError("Profile must match ^[a-z0-9][a-z0-9._-]{0,62}$")
240
+ return value
241
+
242
+
243
+ def onboarding_state_path(data_root: Path, profile: str) -> Path:
244
+ return data_root / "onboarding" / "profiles" / f"{safe_profile(profile)}.json"
245
+
246
+
247
+ def read_onboarding_state(data_root: Path, profile: str, required: bool = True) -> dict | None:
248
+ path = onboarding_state_path(data_root, profile)
249
+ if not path.is_file():
250
+ if required:
251
+ raise OnboardingError(f"Onboarding profile not found: {path}")
252
+ return None
253
+ state = read_json_object(path, "Onboarding state")
254
+ if state.get("schemaVersion") != ONBOARDING_STATE_SCHEMA:
255
+ raise OnboardingError("Unexpected onboarding state schema")
256
+ if state.get("profile") != profile:
257
+ raise OnboardingError("Onboarding profile does not match its state file")
258
+ if normalized(state.get("dataRoot", "")) != data_root:
259
+ raise OnboardingError("Onboarding state data root does not match the requested data root")
260
+ return state
261
+
262
+
263
+ def write_onboarding_state(data_root: Path, state: dict) -> Path:
264
+ state["updatedAt"] = now_utc()
265
+ path = onboarding_state_path(data_root, state["profile"])
266
+ atomic_json(path, state)
267
+ if os.name != "nt":
268
+ path.parent.chmod(0o700)
269
+ path.chmod(0o600)
270
+ return path
271
+
272
+
273
+ def step(status: str = "pending", **values: object) -> dict:
274
+ return {"status": status, **values}
275
+
276
+
277
+ def mark_step(state: dict, name: str, status: str, **values: object) -> None:
278
+ state.setdefault("steps", {})[name] = step(status, at=now_utc(), **values)
279
+
280
+
281
+ def validate_connection(payload: dict) -> dict:
282
+ if payload.get("schemaVersion") != CONNECTION_SCHEMA:
283
+ raise OnboardingError(f"connection schemaVersion must be {CONNECTION_SCHEMA}")
284
+ allowed = {
285
+ "schemaVersion", "name", "type", "jdbcUrl", "username", "passwordEnv",
286
+ "opaqueProfileId", "opaqueRevision",
287
+ "profile", "namespace", "schemas", "modelsDir", "evidenceDir", "readOnlyRecommended",
288
+ }
289
+ unexpected = sorted(set(payload) - allowed)
290
+ if unexpected:
291
+ raise OnboardingError(f"Unsupported connection fields: {', '.join(unexpected)}")
292
+ opaque = payload.get("opaqueProfileId") is not None
293
+ required = ("name", "type", "namespace") if opaque else ("name", "type", "jdbcUrl", "namespace")
294
+ for name in required:
295
+ if not isinstance(payload.get(name), str) or not payload[name].strip():
296
+ raise OnboardingError(f"connection.{name} must be a non-empty string")
297
+ password_env = payload.get("passwordEnv")
298
+ jdbc_url = None
299
+ if opaque:
300
+ if not isinstance(payload.get("opaqueProfileId"), str) or not OPAQUE_PROFILE_PATTERN.fullmatch(payload["opaqueProfileId"]):
301
+ raise OnboardingError("connection.opaqueProfileId must be an opaque Foggy profile ID")
302
+ if not isinstance(payload.get("opaqueRevision"), str) or not OPAQUE_REVISION_PATTERN.fullmatch(payload["opaqueRevision"]):
303
+ raise OnboardingError("connection.opaqueRevision must be a sha256 revision")
304
+ exposed = sorted(name for name in ("jdbcUrl", "username", "passwordEnv") if name in payload)
305
+ if exposed:
306
+ raise OnboardingError(f"Opaque connection plans must not contain: {', '.join(exposed)}")
307
+ else:
308
+ if password_env is not None and (not isinstance(password_env, str) or not ENV_NAME_PATTERN.fullmatch(password_env)):
309
+ raise OnboardingError("connection.passwordEnv must be an environment variable name")
310
+ jdbc_url = payload["jdbcUrl"].strip()
311
+ if re.search(r"(?i)(?:password|passwd|pwd)\s*=", jdbc_url) or re.search(r"//[^/@:]+:[^/@]+@", jdbc_url):
312
+ raise OnboardingError("Do not embed passwords in jdbcUrl; use passwordEnv")
313
+ schemas = payload.get("schemas", [])
314
+ if not isinstance(schemas, list) or any(not isinstance(item, str) or not item.strip() for item in schemas):
315
+ raise OnboardingError("connection.schemas must be an array of non-empty strings")
316
+ if payload.get("username") is not None and not isinstance(payload["username"], str):
317
+ raise OnboardingError("connection.username must be a string")
318
+ if not isinstance(payload.get("modelsDir", "models"), str) or not payload.get("modelsDir", "models").strip():
319
+ raise OnboardingError("connection.modelsDir must be a non-empty string")
320
+ if payload.get("profile") is not None:
321
+ safe_profile(payload["profile"])
322
+ if payload.get("evidenceDir") is not None and (not isinstance(payload["evidenceDir"], str) or not payload["evidenceDir"].strip()):
323
+ raise OnboardingError("connection.evidenceDir must be a non-empty string")
324
+ result = {
325
+ "schemaVersion": CONNECTION_SCHEMA,
326
+ "connectionMode": "opaque-profile" if opaque else "legacy-inline",
327
+ "name": payload["name"].strip(),
328
+ "type": payload["type"].strip().lower(),
329
+ "namespace": payload["namespace"].strip(),
330
+ "schemas": [item.strip() for item in schemas],
331
+ "modelsDir": payload.get("modelsDir", "models"),
332
+ "readOnlyRecommended": payload.get("readOnlyRecommended", True),
333
+ }
334
+ if opaque:
335
+ result["opaqueProfileId"] = payload["opaqueProfileId"]
336
+ result["opaqueRevision"] = payload["opaqueRevision"]
337
+ else:
338
+ result["jdbcUrl"] = jdbc_url
339
+ result["username"] = payload.get("username")
340
+ result["passwordEnv"] = password_env
341
+ if payload.get("profile") is not None:
342
+ result["profile"] = safe_profile(payload["profile"])
343
+ if payload.get("evidenceDir") is not None:
344
+ result["evidenceDir"] = payload["evidenceDir"].strip()
345
+ if result["type"] not in {"sqlite", "mysql", "postgres", "postgresql"}:
346
+ raise OnboardingError("Initial onboarding supports sqlite, mysql, postgres, and postgresql")
347
+ if not opaque and result["type"] != "sqlite" and not result["passwordEnv"]:
348
+ raise OnboardingError("Non-SQLite connections require passwordEnv")
349
+ return result
350
+
351
+
352
+ def validate_semantic_plan(payload: dict) -> dict:
353
+ if payload.get("schemaVersion") != SEMANTIC_PLAN_SCHEMA:
354
+ raise OnboardingError(f"semantic plan schemaVersion must be {SEMANTIC_PLAN_SCHEMA}")
355
+ allowed = {"schemaVersion", "profile", "draftDir", "bundleName", "evidenceDir", "queryModels"}
356
+ unexpected = sorted(set(payload) - allowed)
357
+ if unexpected:
358
+ raise OnboardingError(f"Unsupported semantic plan fields: {', '.join(unexpected)}")
359
+ for name in ("draftDir", "bundleName"):
360
+ if not isinstance(payload.get(name), str) or not payload[name].strip():
361
+ raise OnboardingError(f"semanticPlan.{name} must be a non-empty string")
362
+ query_models = payload.get("queryModels")
363
+ if not isinstance(query_models, list) or not query_models:
364
+ raise OnboardingError("semanticPlan.queryModels must be a non-empty array")
365
+ if any(not isinstance(item, str) or not MODEL_NAME_PATTERN.fullmatch(item) for item in query_models):
366
+ raise OnboardingError("Every query model must be a stable identifier")
367
+ if len(set(query_models)) != len(query_models):
368
+ raise OnboardingError("semanticPlan.queryModels must not contain duplicates")
369
+ result = {
370
+ "schemaVersion": SEMANTIC_PLAN_SCHEMA,
371
+ "draftDir": payload["draftDir"].strip(),
372
+ "bundleName": payload["bundleName"].strip(),
373
+ "queryModels": query_models,
374
+ }
375
+ if payload.get("profile") is not None:
376
+ result["profile"] = safe_profile(payload["profile"])
377
+ if payload.get("evidenceDir") is not None:
378
+ if not isinstance(payload["evidenceDir"], str) or not payload["evidenceDir"].strip():
379
+ raise OnboardingError("semanticPlan.evidenceDir must be a non-empty string")
380
+ result["evidenceDir"] = payload["evidenceDir"].strip()
381
+ return result
382
+
383
+
384
+ def semantic_files(root: Path) -> list[Path]:
385
+ if not root.is_dir():
386
+ raise OnboardingError(f"Semantic directory not found: {root}")
387
+ if root.is_symlink():
388
+ raise OnboardingError(f"Semantic directory cannot be a symlink: {root}")
389
+ files = sorted(
390
+ (item for item in root.rglob("*") if item.is_file() and item.suffix.lower() in {".tm", ".qm"}),
391
+ key=lambda item: item.relative_to(root).as_posix(),
392
+ )
393
+ if len(files) > 500:
394
+ raise OnboardingError("Semantic directory contains more than 500 TM/QM files; narrow the draft scope")
395
+ for item in files:
396
+ if item.is_symlink() or not is_child(item.resolve(strict=False), root.resolve(strict=False)):
397
+ raise OnboardingError(f"Semantic file must stay inside the non-symlink draft directory: {item}")
398
+ return files
399
+
400
+
401
+ def semantic_manifest(root: Path, require_pair: bool = True) -> dict:
402
+ files = semantic_files(root)
403
+ tm_count = sum(item.suffix.lower() == ".tm" for item in files)
404
+ qm_count = sum(item.suffix.lower() == ".qm" for item in files)
405
+ if require_pair and (tm_count == 0 or qm_count == 0):
406
+ raise OnboardingError("Semantic draft must contain at least one .tm and one .qm file")
407
+ entries = []
408
+ secret_assignment = re.compile(r"(?i)(?:jdbc:|password\s*[:=]|api[_-]?key\s*[:=]|secret\s*[:=])")
409
+ for item in files:
410
+ try:
411
+ content = item.read_text(encoding="utf-8-sig")
412
+ except UnicodeDecodeError as exc:
413
+ raise OnboardingError(f"Semantic file must be valid UTF-8: {item.name}") from exc
414
+ if secret_assignment.search(content):
415
+ raise OnboardingError(f"Semantic file appears to contain a connection or secret assignment: {item.name}")
416
+ entries.append({
417
+ "path": item.relative_to(root).as_posix(),
418
+ "sha256": sha256(item),
419
+ "size": item.stat().st_size,
420
+ "type": item.suffix.lower()[1:],
421
+ })
422
+ digest = hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
423
+ return {"root": str(root), "digest": digest, "tmCount": tm_count, "qmCount": qm_count, "files": entries}
424
+
425
+
426
+ def require_unchanged_semantic_draft(state: dict) -> tuple[Path, dict]:
427
+ semantic = state.get("semantic", {})
428
+ draft_dir = normalized(semantic.get("draftDir", ""))
429
+ expected = semantic.get("draftManifest")
430
+ if not isinstance(expected, dict):
431
+ raise OnboardingError("Semantic draft is not registered; run semantic-draft first")
432
+ current = semantic_manifest(draft_dir)
433
+ if current["digest"] != expected.get("digest"):
434
+ raise OnboardingError("Semantic draft changed after registration; rerun semantic-draft and validate again")
435
+ return draft_dir, current
436
+
437
+
438
+ def semantic_diff(draft_manifest: dict, models_dir: Path, prune: bool) -> dict:
439
+ source = {item["path"]: item for item in draft_manifest["files"]}
440
+ target_manifest = semantic_manifest(models_dir, require_pair=False) if models_dir.is_dir() else {"files": []}
441
+ target = {item["path"]: item for item in target_manifest["files"]}
442
+ added = sorted(path for path in source if path not in target)
443
+ updated = sorted(path for path in source if path in target and source[path]["sha256"] != target[path]["sha256"])
444
+ unchanged = sorted(path for path in source if path in target and source[path]["sha256"] == target[path]["sha256"])
445
+ target_only = sorted(path for path in target if path not in source)
446
+ return {
447
+ "added": added,
448
+ "updated": updated,
449
+ "unchanged": unchanged,
450
+ "removed": target_only if prune else [],
451
+ "preserved": [] if prune else target_only,
452
+ "prune": prune,
453
+ }
454
+
455
+
456
+ def copy_file_atomic(source: Path, destination: Path) -> None:
457
+ destination.parent.mkdir(parents=True, exist_ok=True)
458
+ if destination.exists() and destination.is_symlink():
459
+ raise OnboardingError(f"Refusing to overwrite symlink: {destination}")
460
+ temporary = destination.with_name(destination.name + ".foggy-onboarding.tmp")
461
+ shutil.copy2(source, temporary)
462
+ os.replace(temporary, destination)
463
+
464
+
465
+ def publish_files(draft_dir: Path, models_dir: Path, diff: dict, backup_root: Path) -> dict:
466
+ backup_dir = backup_root / dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
467
+ backup_dir.mkdir(parents=True, exist_ok=False)
468
+ changed_existing = diff["updated"] + diff["removed"]
469
+ for relative in changed_existing:
470
+ source = models_dir / Path(relative)
471
+ copy_file_atomic(source, backup_dir / Path(relative))
472
+ manifest = {
473
+ "schemaVersion": "foggy-deepseek-semantic-backup/v1",
474
+ "createdAt": now_utc(),
475
+ "modelsDir": str(models_dir),
476
+ "added": diff["added"],
477
+ "backedUp": changed_existing,
478
+ }
479
+ atomic_json(backup_dir / "backup-manifest.json", manifest)
480
+ backup = {"backupDir": str(backup_dir), **manifest}
481
+ try:
482
+ for relative in diff["added"] + diff["updated"]:
483
+ copy_file_atomic(draft_dir / Path(relative), models_dir / Path(relative))
484
+ for relative in diff["removed"]:
485
+ target = models_dir / Path(relative)
486
+ if target.is_symlink() or not is_child(target, models_dir):
487
+ raise OnboardingError(f"Refusing to remove unsafe semantic target: {target}")
488
+ target.unlink()
489
+ except Exception:
490
+ rollback_published_files(backup)
491
+ raise
492
+ return backup
493
+
494
+
495
+ def rollback_published_files(backup: dict) -> None:
496
+ models_dir = normalized(backup["modelsDir"])
497
+ backup_dir = normalized(backup["backupDir"])
498
+ for relative in backup["added"]:
499
+ target = models_dir / Path(relative)
500
+ if target.is_file() and not target.is_symlink() and is_child(target, models_dir):
501
+ target.unlink()
502
+ for relative in backup["backedUp"]:
503
+ copy_file_atomic(backup_dir / Path(relative), models_dir / Path(relative))
504
+
505
+
506
+ def safe_extract(zip_path: Path, destination: Path) -> None:
507
+ with zipfile.ZipFile(zip_path) as archive:
508
+ for member in archive.infolist():
509
+ name = PurePosixPath(member.filename)
510
+ if name.is_absolute() or ".." in name.parts:
511
+ raise OnboardingError(f"Unsafe zip member: {member.filename}")
512
+ archive.extractall(destination)
513
+
514
+
515
+ def install_analysis_skill(zip_path: Path, project_root: Path, version: str, expected_hash: str, replace: bool) -> dict:
516
+ skills_root = project_root / ".agents" / "skills"
517
+ destination = skills_root / "foggy-ai-analysis"
518
+ marker = destination / ".foggy-onboarding-install.json"
519
+ if destination.exists():
520
+ if marker.is_file():
521
+ installed = json.loads(marker.read_text(encoding="utf-8"))
522
+ if installed.get("archiveSha256") == expected_hash:
523
+ return {"path": str(destination), "version": version, "action": "kept-matching"}
524
+ if not replace:
525
+ raise OnboardingError(f"Analysis Skill already exists and is not managed at {destination}; rerun with --replace-skill to back it up")
526
+ backup_root = project_root / ".foggy" / "onboarding-backups"
527
+ backup_root.mkdir(parents=True, exist_ok=True)
528
+ backup = backup_root / f"foggy-ai-analysis-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
529
+ shutil.move(str(destination), str(backup))
530
+ skills_root.mkdir(parents=True, exist_ok=True)
531
+ with tempfile.TemporaryDirectory(prefix="foggy-skill-") as temporary:
532
+ extract_root = Path(temporary)
533
+ safe_extract(zip_path, extract_root)
534
+ candidates = list(extract_root.rglob("SKILL.md"))
535
+ if len(candidates) != 1:
536
+ raise OnboardingError(f"Expected exactly one SKILL.md in analysis Skill archive, found {len(candidates)}")
537
+ source = candidates[0].parent
538
+ shutil.copytree(source, destination)
539
+ atomic_json(marker, {"schemaVersion": "foggy-installed-skill/v1", "version": version, "archiveSha256": expected_hash})
540
+ return {"path": str(destination), "version": version, "action": "installed"}
541
+
542
+
543
+ def install_onboarding_skill(project_root: Path) -> dict:
544
+ destination = project_root / ".agents" / "skills" / "foggy-deepseek-onboarding"
545
+ source = skill_root()
546
+ if source.resolve() == destination.resolve(strict=False):
547
+ return {"path": str(destination), "action": "already-running-from-target"}
548
+ if destination.exists():
549
+ return {"path": str(destination), "action": "kept-existing"}
550
+ destination.parent.mkdir(parents=True, exist_ok=True)
551
+ shutil.copytree(source, destination)
552
+ return {"path": str(destination), "action": "installed"}
553
+
554
+
555
+ def read_install_state(install_root: Path, required: bool = True) -> dict | None:
556
+ path = install_root / "install-state.json"
557
+ if not path.is_file():
558
+ if required:
559
+ raise OnboardingError(f"Install state not found: {path}")
560
+ return None
561
+ state = json.loads(path.read_text(encoding="utf-8"))
562
+ if state.get("schemaVersion") != STATE_SCHEMA:
563
+ raise OnboardingError("Unexpected install state schema")
564
+ if normalized(state.get("installRoot", "")) != install_root:
565
+ raise OnboardingError("Install state root does not match requested install root")
566
+ return state
567
+
568
+
569
+ def java_probe() -> dict:
570
+ result = command_result([os.environ.get("JAVA_EXE", "java"), "-version"])
571
+ combined = "\n".join(part for part in (result["stderr"], result["stdout"]) if part)
572
+ result["version"] = ".".join(str(item) for item in version_tuple(combined)) if version_tuple(combined) else None
573
+ return result
574
+
575
+
576
+ def install_command(args: argparse.Namespace) -> dict:
577
+ versions = load_versions()
578
+ install_root = normalized(args.install_root or default_install_root())
579
+ data_root = normalized(args.data_root or default_data_root())
580
+ project_root = normalized(args.project_root or Path.cwd())
581
+ cache_dirs = [normalized(item) for item in args.asset_cache_dir]
582
+ components = versions["components"]
583
+ assert_managed_root(install_root, "Install root")
584
+ assert_managed_root(data_root, "Data root")
585
+ if install_root == data_root:
586
+ raise OnboardingError("Install root and data root must be different")
587
+ plan = {
588
+ "schemaVersion": "foggy-deepseek-onboarding-plan/v1",
589
+ "installRoot": str(install_root),
590
+ "dataRoot": str(data_root),
591
+ "projectRoot": str(project_root),
592
+ "versions": {name: value.get("version") for name, value in components.items()},
593
+ "operations": ["install isolated CLI", "verify Launcher assets", "install project Skills", "write install state"],
594
+ "productionReady": False,
595
+ }
596
+ if args.dry_run:
597
+ return {"success": True, "dryRun": True, "plan": plan}
598
+ if sys.version_info < (3, 11):
599
+ raise OnboardingError(f"Python 3.11+ required, got {sys.version.split()[0]}")
600
+ if not project_root.is_dir():
601
+ raise OnboardingError(f"Project root not found: {project_root}")
602
+ install_root.mkdir(parents=True, exist_ok=True)
603
+ data_root.mkdir(parents=True, exist_ok=True)
604
+ downloads = install_root / "downloads"
605
+ verified: list[dict] = []
606
+
607
+ cli_component = components["cli"]
608
+ for role in ("wheel", "checksums"):
609
+ asset = cli_component[role]
610
+ verified.append(materialize(asset, downloads / "cli" / asset["file"], cache_dirs))
611
+ checksum_lines = (downloads / "cli" / cli_component["checksums"]["file"]).read_text(encoding="utf-8").splitlines()
612
+ checksum_entries = {line.split(maxsplit=1)[1].strip(): line.split(maxsplit=1)[0].lower() for line in checksum_lines if len(line.split(maxsplit=1)) == 2}
613
+ wheel_asset = cli_component["wheel"]
614
+ if checksum_entries.get(wheel_asset["file"]) != wheel_asset["sha256"]:
615
+ raise OnboardingError("Pinned CLI SHA256SUMS does not match the pinned wheel hash")
616
+ if args.skip_cli_install:
617
+ cli_command = normalized(args.cli_command or shutil.which("foggy-runtime") or "")
618
+ if not cli_command.is_file():
619
+ raise OnboardingError("--skip-cli-install requires --cli-command or foggy-runtime on PATH")
620
+ cli_mode = "external"
621
+ else:
622
+ python_path = venv_python(install_root)
623
+ if not python_path.is_file():
624
+ venv.EnvBuilder(with_pip=True).create(install_root / "venv")
625
+ wheel = downloads / "cli" / cli_component["wheel"]["file"]
626
+ command_result(
627
+ [
628
+ str(python_path), "-m", "pip", "install", "--upgrade",
629
+ "--no-deps", "--disable-pip-version-check", str(wheel),
630
+ ],
631
+ timeout=300,
632
+ check=True,
633
+ )
634
+ cli_command = venv_cli(install_root)
635
+ cli_mode = "managed-venv"
636
+ cli_version = command_result([str(cli_command), "--version"], check=True)
637
+ actual_cli_version = version_tuple(cli_version["stdout"])[:3]
638
+ pinned_cli_version = version_tuple(cli_component["version"])[:3]
639
+ version_matches = actual_cli_version >= pinned_cli_version if cli_mode == "external" else actual_cli_version == pinned_cli_version
640
+ if not version_matches:
641
+ raise OnboardingError(f"Unexpected CLI version: {cli_version['stdout']}")
642
+
643
+ launcher_dir = install_root / "launcher"
644
+ for asset in components["launcher"]["assets"]:
645
+ verified.append(materialize(asset, launcher_dir / asset["file"], cache_dirs))
646
+ if os.name != "nt":
647
+ (launcher_dir / "start-foggy-runtime.sh").chmod(0o755)
648
+
649
+ analysis_assets = components["analysisSkill"]["assets"]
650
+ for asset in analysis_assets:
651
+ verified.append(materialize(asset, downloads / "skill" / asset["file"], cache_dirs))
652
+ zip_asset = next(item for item in analysis_assets if item["role"] == "zip")
653
+ analysis_skill = install_analysis_skill(
654
+ downloads / "skill" / zip_asset["file"], project_root, components["analysisSkill"]["version"], zip_asset["sha256"], args.replace_skill
655
+ )
656
+ onboarding_skill = install_onboarding_skill(project_root)
657
+ state = {
658
+ "schemaVersion": STATE_SCHEMA,
659
+ "installedAt": now_utc(),
660
+ "packageVersion": versions["packageVersion"],
661
+ "installRoot": str(install_root),
662
+ "dataRoot": str(data_root),
663
+ "projectRoot": str(project_root),
664
+ "cli": {"version": cli_component["version"], "command": str(cli_command), "mode": cli_mode},
665
+ "launcher": {"version": components["launcher"]["version"], "path": str(launcher_dir)},
666
+ "skills": {"onboarding": onboarding_skill, "analysis": analysis_skill},
667
+ "verifiedAssets": verified,
668
+ "securityMode": versions["defaults"]["securityMode"],
669
+ "productionReady": False,
670
+ }
671
+ atomic_json(install_root / "install-state.json", state)
672
+ return {
673
+ "success": True,
674
+ "schemaVersion": "foggy-deepseek-onboarding-install-result/v1",
675
+ "statePath": str(install_root / "install-state.json"),
676
+ "installRoot": str(install_root),
677
+ "dataRoot": str(data_root),
678
+ "projectRoot": str(project_root),
679
+ "cliVersion": cli_component["version"],
680
+ "launcherVersion": components["launcher"]["version"],
681
+ "analysisSkill": analysis_skill,
682
+ "onboardingSkill": onboarding_skill,
683
+ "java": java_probe(),
684
+ "next": "run doctor, then runtime-start",
685
+ "productionReady": False,
686
+ }
687
+
688
+
689
+ def process_info(pid: int) -> dict:
690
+ if pid <= 0:
691
+ return {"running": False, "commandLine": ""}
692
+ if os.name == "nt":
693
+ script = f"$p=Get-CimInstance Win32_Process -Filter \"ProcessId = {pid}\" -ErrorAction SilentlyContinue; if($p){{$p.CommandLine}}"
694
+ result = command_result(["powershell", "-NoProfile", "-Command", script], timeout=15)
695
+ if result["exitCode"] != 0:
696
+ fallback_script = f"$p=Get-Process -Id {pid} -ErrorAction SilentlyContinue; if($p){{$p.Id}}"
697
+ fallback = command_result(["powershell", "-NoProfile", "-Command", fallback_script], timeout=15)
698
+ return {
699
+ "running": fallback["exitCode"] == 0 and fallback["stdout"].strip() == str(pid),
700
+ "commandLine": "",
701
+ "inspectionError": "Windows process command line inspection failed",
702
+ }
703
+ command_line = result["stdout"]
704
+ return {"running": bool(command_line), "commandLine": command_line}
705
+ proc = Path("/proc") / str(pid) / "cmdline"
706
+ if not proc.is_file():
707
+ return {"running": False, "commandLine": ""}
708
+ try:
709
+ command_line = proc.read_bytes().replace(b"\0", b" ").decode("utf-8", errors="replace").strip()
710
+ except OSError:
711
+ command_line = ""
712
+ return {"running": bool(command_line), "commandLine": command_line}
713
+
714
+
715
+ def stop_recorded_runtime(data_root: Path, force: bool = False) -> dict:
716
+ state_path = data_root / "runtime-state.json"
717
+ if not state_path.is_file():
718
+ return {"success": True, "action": "already-stopped", "statePath": str(state_path)}
719
+ state = json.loads(state_path.read_text(encoding="utf-8"))
720
+ if state.get("schemaVersion") != RUNTIME_STATE_SCHEMA:
721
+ raise OnboardingError("Unexpected Runtime state schema")
722
+ pid = int(state["pid"])
723
+ expected_jar = state["launcherJar"]
724
+ info = process_info(pid)
725
+ if not info["running"]:
726
+ state_path.unlink()
727
+ return {"success": True, "action": "stale-state-removed", "pid": pid}
728
+ if Path(expected_jar).name not in info["commandLine"]:
729
+ raise OnboardingError(f"Refusing to stop PID {pid}: command line does not contain expected Launcher jar")
730
+ os.kill(pid, signal.SIGTERM)
731
+ deadline = time.monotonic() + 20
732
+ while time.monotonic() < deadline and process_info(pid)["running"]:
733
+ time.sleep(0.25)
734
+ if process_info(pid)["running"] and force:
735
+ if os.name == "nt":
736
+ command_result(["taskkill", "/PID", str(pid), "/T", "/F"], timeout=20, check=True)
737
+ else:
738
+ os.kill(pid, signal.SIGKILL)
739
+ if process_info(pid)["running"]:
740
+ raise OnboardingError(f"Runtime PID {pid} did not stop; rerun with --force")
741
+ state_path.unlink()
742
+ return {"success": True, "action": "stopped", "pid": pid, "statePath": str(state_path)}
743
+
744
+
745
+ def parse_json_output(result: dict, label: str) -> dict:
746
+ if result["exitCode"] != 0:
747
+ try:
748
+ failed = json.loads(result["stdout"])
749
+ except (json.JSONDecodeError, TypeError):
750
+ failed = None
751
+ error = failed.get("error") if isinstance(failed, dict) else None
752
+ if isinstance(error, dict):
753
+ parts = [str(error.get(name)) for name in ("code", "phase", "message") if error.get(name)]
754
+ detail = " | ".join(parts)
755
+ else:
756
+ detail = None
757
+ raise OnboardingError(f"{label} failed with exit code {result['exitCode']}" + (f": {detail}" if detail else ""))
758
+ try:
759
+ return json.loads(result["stdout"])
760
+ except json.JSONDecodeError as exc:
761
+ raise OnboardingError(f"{label} did not return JSON") from exc
762
+
763
+
764
+ def runtime_start_command(args: argparse.Namespace) -> dict:
765
+ versions = load_versions()
766
+ install_root = normalized(args.install_root or default_install_root())
767
+ state = read_install_state(install_root)
768
+ data_root = normalized(args.data_root or state["dataRoot"])
769
+ existing = data_root / "runtime-state.json"
770
+ if existing.is_file():
771
+ prior = json.loads(existing.read_text(encoding="utf-8"))
772
+ if process_info(int(prior.get("pid", 0)))["running"]:
773
+ raise OnboardingError(f"Recorded Runtime is already running with PID {prior['pid']}")
774
+ existing.unlink()
775
+ port = args.port or int(versions["defaults"]["port"])
776
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
777
+ try:
778
+ probe.bind(("127.0.0.1", port))
779
+ except OSError as exc:
780
+ raise OnboardingError(f"Port {port} is not available") from exc
781
+ work_dir = data_root / "runtime"
782
+ work_dir.mkdir(parents=True, exist_ok=True)
783
+ launcher_dir = normalized(state["launcher"]["path"])
784
+ java_exe = args.java or os.environ.get("JAVA_EXE", "java")
785
+ if os.name == "nt":
786
+ shell = shutil.which("pwsh") or shutil.which("powershell")
787
+ if not shell:
788
+ raise OnboardingError("PowerShell is required to start the Windows Launcher")
789
+ command = [shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(launcher_dir / "start-foggy-runtime.ps1"), "-Port", str(port), "-WorkDir", str(work_dir), "-JavaExe", java_exe]
790
+ # Do not use PIPE here. A long-lived process spawned by Windows PowerShell can inherit the
791
+ # capture handles and keep subprocess.run waiting for EOF after the launcher script exits.
792
+ launcher_stdout = work_dir / "launcher-command.stdout.json"
793
+ launcher_stderr = work_dir / "launcher-command.stderr.log"
794
+ started = time.monotonic()
795
+ with launcher_stdout.open("w", encoding="utf-8") as stdout_stream, launcher_stderr.open("w", encoding="utf-8") as stderr_stream:
796
+ process = subprocess.run(command, stdout=stdout_stream, stderr=stderr_stream, stdin=subprocess.DEVNULL, timeout=60, check=False)
797
+ launch_result = {
798
+ "command": shell,
799
+ "available": True,
800
+ "exitCode": process.returncode,
801
+ "stdout": launcher_stdout.read_text(encoding="utf-8", errors="replace").strip(),
802
+ "stderr": launcher_stderr.read_text(encoding="utf-8", errors="replace").strip(),
803
+ "durationMs": round((time.monotonic() - started) * 1000),
804
+ }
805
+ if process.returncode != 0:
806
+ raise OnboardingError(f"Launcher failed: {launch_result['stderr'] or launch_result['stdout']}")
807
+ else:
808
+ environment = os.environ.copy()
809
+ environment.update({"PORT": str(port), "WORK_DIR": str(work_dir), "JAVA_EXE": java_exe})
810
+ started = time.monotonic()
811
+ process = subprocess.run(
812
+ ["bash", str(launcher_dir / "start-foggy-runtime.sh")],
813
+ capture_output=True,
814
+ text=True,
815
+ encoding="utf-8",
816
+ errors="replace",
817
+ timeout=60,
818
+ env=environment,
819
+ check=False,
820
+ )
821
+ launch_result = {"command": "bash", "available": True, "exitCode": process.returncode, "stdout": process.stdout.strip(), "stderr": process.stderr.strip(), "durationMs": round((time.monotonic() - started) * 1000)}
822
+ if process.returncode != 0:
823
+ raise OnboardingError(f"Launcher failed: {process.stderr or process.stdout}")
824
+ launch = parse_json_output(launch_result, "Launcher")
825
+ pid = int(launch["pid"])
826
+ cli = state["cli"]["command"]
827
+ namespace = args.namespace or versions["defaults"]["namespace"]
828
+ base_url = launch["runtimeUrl"]
829
+ evidence_dir = data_root / "evidence" / f"runtime-start-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
830
+ evidence_dir.mkdir(parents=True, exist_ok=True)
831
+ atomic_json(evidence_dir / "launch.json", launch)
832
+ try:
833
+ wait_result = command_result([cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready", "--timeout-seconds", str(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]), "--interval-seconds", "1"], timeout=(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]) + 30)
834
+ wait_payload = parse_json_output(wait_result, "wait-ready")
835
+ if wait_payload.get("success") is not True:
836
+ raise OnboardingError("wait-ready returned success=false")
837
+ atomic_json(evidence_dir / "wait-ready.json", wait_payload)
838
+ capabilities_result = command_result([cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"], timeout=30)
839
+ capabilities = parse_json_output(capabilities_result, "capabilities")
840
+ expected_contract = versions["components"]["launcher"]["runtimeApiContract"]
841
+ if capabilities.get("success") is not True or capabilities.get("runtimeApiVersion") != expected_contract:
842
+ raise OnboardingError(f"Unexpected Runtime API contract; expected {expected_contract}")
843
+ if capabilities.get("data", {}).get("securityMode") != versions["defaults"]["securityMode"]:
844
+ raise OnboardingError("Packaged Launcher did not report the expected dev/test security mode")
845
+ atomic_json(evidence_dir / "capabilities.json", capabilities)
846
+ except Exception:
847
+ try:
848
+ os.kill(pid, signal.SIGTERM)
849
+ except OSError:
850
+ pass
851
+ raise
852
+ runtime_state = {
853
+ "schemaVersion": RUNTIME_STATE_SCHEMA,
854
+ "startedAt": now_utc(),
855
+ "pid": pid,
856
+ "runtimeUrl": base_url,
857
+ "port": port,
858
+ "namespace": namespace,
859
+ "workDir": str(work_dir),
860
+ "launcherJar": str(launcher_dir / f"foggy-runtime-launcher-{state['launcher']['version']}.jar"),
861
+ "evidenceDir": str(evidence_dir),
862
+ "identity": {
863
+ "engine": capabilities.get("engine"),
864
+ "runtimeApiVersion": capabilities.get("runtimeApiVersion"),
865
+ "schemaVersion": capabilities.get("data", {}).get("schemaVersion"),
866
+ "securityMode": capabilities.get("data", {}).get("securityMode"),
867
+ },
868
+ }
869
+ atomic_json(data_root / "runtime-state.json", runtime_state)
870
+ return {"success": True, **runtime_state, "productionReady": False}
871
+
872
+
873
+ def onboarding_context(args: argparse.Namespace, require_runtime: bool = False) -> tuple[Path, dict, Path, dict | None]:
874
+ install_root = normalized(args.install_root or default_install_root())
875
+ install_state = read_install_state(install_root)
876
+ data_root = normalized(args.data_root or install_state["dataRoot"])
877
+ assert_managed_root(data_root, "Data root")
878
+ runtime_state_path = data_root / "runtime-state.json"
879
+ runtime_state = read_json_object(runtime_state_path, "Runtime state") if runtime_state_path.is_file() else None
880
+ if runtime_state and runtime_state.get("schemaVersion") != RUNTIME_STATE_SCHEMA:
881
+ raise OnboardingError("Unexpected Runtime state schema")
882
+ if require_runtime:
883
+ if not runtime_state:
884
+ raise OnboardingError("Runtime is not started; run runtime-start first")
885
+ if not process_info(int(runtime_state.get("pid", 0)))["running"]:
886
+ raise OnboardingError("Runtime state is stale; restart Runtime before continuing")
887
+ return install_root, install_state, data_root, runtime_state
888
+
889
+
890
+ def cli_json(install_state: dict, runtime_state: dict, namespace: str, command: list[str], label: str, timeout: int = 60) -> dict:
891
+ invocation = [
892
+ install_state["cli"]["command"],
893
+ "--base-url", runtime_state["runtimeUrl"],
894
+ "--namespace", namespace,
895
+ "--output", "json",
896
+ *command,
897
+ ]
898
+ payload = parse_json_output(command_result(invocation, timeout=timeout), label)
899
+ if payload.get("success") is not True:
900
+ error = payload.get("error")
901
+ if isinstance(error, dict):
902
+ detail = error.get("message") or error.get("code")
903
+ else:
904
+ detail = error
905
+ raise OnboardingError(f"{label} returned success=false: {detail or 'unknown Runtime error'}")
906
+ return payload
907
+
908
+
909
+ CONNECTION_SECRET_KEYS = {
910
+ "jdbcurl", "url", "username", "password", "passwordenv", "passwordref",
911
+ }
912
+
913
+
914
+ def redact_connection_material(value: Any) -> Any:
915
+ """Remove connection material from Runtime payloads before returning or persisting them."""
916
+ if isinstance(value, dict):
917
+ return {
918
+ key: redact_connection_material(item)
919
+ for key, item in value.items()
920
+ if key.lower() not in CONNECTION_SECRET_KEYS
921
+ }
922
+ if isinstance(value, list):
923
+ return [redact_connection_material(item) for item in value]
924
+ if isinstance(value, str) and "jdbc:" in value.lower():
925
+ return "[REDACTED_CONNECTION_URL]"
926
+ return value
927
+
928
+
929
+ def require_opaque_profile_cli(install_state: dict) -> None:
930
+ probe = command_result([install_state["cli"]["command"], "profiles", "--help"])
931
+ if not probe["available"] or probe["exitCode"] != 0:
932
+ raise OnboardingError(
933
+ "Opaque datasource onboarding requires foggy-runtime-cli 0.1.23+ with the profiles command"
934
+ )
935
+
936
+
937
+ def onboarding_plan_command(args: argparse.Namespace) -> dict:
938
+ install_root, install_state, data_root, runtime_state = onboarding_context(args, require_runtime=False)
939
+ profile = safe_profile(args.profile)
940
+ project_root = normalized(args.project_root or install_state["projectRoot"])
941
+ if not project_root.is_dir():
942
+ raise OnboardingError(f"Project root not found: {project_root}")
943
+ connection_file = normalized(args.connection_file)
944
+ connection = validate_connection(read_json_object(connection_file, "Connection plan"))
945
+ if connection.get("connectionMode") == "opaque-profile":
946
+ require_opaque_profile_cli(install_state)
947
+ existing = read_onboarding_state(data_root, profile, required=False)
948
+ if existing and not args.replace_plan:
949
+ raise OnboardingError(f"Onboarding profile already exists: {profile}; use --replace-plan to replace its non-secret plan")
950
+ models_dir = normalized(project_root / connection["modelsDir"])
951
+ if not is_child(models_dir, project_root):
952
+ raise OnboardingError("modelsDir must stay inside projectRoot")
953
+ if models_dir == project_root:
954
+ raise OnboardingError("modelsDir cannot be the project root")
955
+ state = {
956
+ "schemaVersion": ONBOARDING_STATE_SCHEMA,
957
+ "profile": profile,
958
+ "createdAt": existing.get("createdAt", now_utc()) if existing else now_utc(),
959
+ "updatedAt": now_utc(),
960
+ "installRoot": str(install_root),
961
+ "dataRoot": str(data_root),
962
+ "projectRoot": str(project_root),
963
+ "runtime": {
964
+ "available": runtime_state is not None,
965
+ "runtimeUrl": runtime_state.get("runtimeUrl") if runtime_state else None,
966
+ "namespace": runtime_state.get("namespace") if runtime_state else None,
967
+ },
968
+ "connection": connection,
969
+ "semantic": {"modelsDir": str(models_dir)},
970
+ "steps": {
971
+ "planned": step("completed", at=now_utc()),
972
+ "datasourceConfigured": step("pending"),
973
+ "datasourceVerified": step("pending"),
974
+ "schemaDiscovered": step("pending"),
975
+ "semanticDrafted": step("pending"),
976
+ "semanticValidated": step("pending"),
977
+ "semanticPublished": step("pending"),
978
+ "semanticVerified": step("pending"),
979
+ },
980
+ "artifacts": {},
981
+ }
982
+ path = write_onboarding_state(data_root, state)
983
+ password_env = connection.get("passwordEnv")
984
+ return {
985
+ "success": True,
986
+ "schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
987
+ "profile": profile,
988
+ "statePath": str(path),
989
+ "connection": {**connection, "passwordEnvPresent": bool(password_env and os.environ.get(password_env))},
990
+ "runtimeAvailable": runtime_state is not None,
991
+ "next": "run datasource-configure --apply after reviewing the plan",
992
+ "productionReady": False,
993
+ }
994
+
995
+
996
+ def require_profile(args: argparse.Namespace, require_runtime: bool = False) -> tuple[dict, dict, Path, dict | None]:
997
+ _install_root, install_state, data_root, runtime_state = onboarding_context(args, require_runtime=require_runtime)
998
+ state = read_onboarding_state(data_root, safe_profile(args.profile))
999
+ if normalized(state["installRoot"]) != normalized(install_state["installRoot"]):
1000
+ raise OnboardingError("Onboarding profile belongs to a different install root")
1001
+ return state, install_state, data_root, runtime_state
1002
+
1003
+
1004
+ def datasource_configure_command(args: argparse.Namespace) -> dict:
1005
+ state, install_state, data_root, runtime_state = require_profile(args, require_runtime=args.apply)
1006
+ connection = state["connection"]
1007
+ opaque = connection.get("connectionMode") == "opaque-profile"
1008
+ plan = {
1009
+ "operation": "profiles apply" if opaque else "datasources add",
1010
+ "name": connection["name"],
1011
+ "type": connection["type"],
1012
+ "namespace": connection["namespace"],
1013
+ "replace": args.replace,
1014
+ }
1015
+ if opaque:
1016
+ plan.update({"profileId": connection["opaqueProfileId"], "revision": connection["opaqueRevision"]})
1017
+ else:
1018
+ plan.update({
1019
+ "jdbcUrl": connection["jdbcUrl"],
1020
+ "username": connection.get("username"),
1021
+ "passwordEnv": connection.get("passwordEnv"),
1022
+ })
1023
+ if not args.apply:
1024
+ return {"success": True, "dryRun": True, "profile": state["profile"], "plan": plan, "next": "rerun with --apply after approval"}
1025
+ if opaque:
1026
+ command = [
1027
+ "profiles", "apply", connection["opaqueProfileId"],
1028
+ "--approve-revision", connection["opaqueRevision"],
1029
+ "--approve-configure",
1030
+ ]
1031
+ label = "opaque profile configure"
1032
+ else:
1033
+ password_env = connection.get("passwordEnv")
1034
+ if password_env and not os.environ.get(password_env):
1035
+ raise OnboardingError(f"Required password environment variable is not present: {password_env}")
1036
+ command = ["datasources", "add", "--name", connection["name"], "--type", connection["type"], "--jdbc-url", connection["jdbcUrl"]]
1037
+ if connection.get("username"):
1038
+ command.extend(["--username", connection["username"]])
1039
+ if password_env:
1040
+ command.extend(["--password-env", password_env])
1041
+ label = "datasources add"
1042
+ if args.replace:
1043
+ command.append("--replace")
1044
+ result = redact_connection_material(
1045
+ cli_json(install_state, runtime_state, connection["namespace"], command, label)
1046
+ )
1047
+ mark_step(state, "datasourceConfigured", "completed", replace=args.replace)
1048
+ path = write_onboarding_state(data_root, state)
1049
+ return {
1050
+ "success": True,
1051
+ "schemaVersion": "foggy-deepseek-datasource-configure/v1",
1052
+ "profile": state["profile"],
1053
+ "statePath": str(path),
1054
+ "dataSource": connection["name"],
1055
+ "runtime": result,
1056
+ "next": "run datasource-verify; add --bind to approve namespace binding",
1057
+ "productionReady": False,
1058
+ }
1059
+
1060
+
1061
+ def datasource_verify_command(args: argparse.Namespace) -> dict:
1062
+ state, install_state, data_root, runtime_state = require_profile(args, require_runtime=True)
1063
+ if state["steps"]["datasourceConfigured"]["status"] != "completed":
1064
+ raise OnboardingError("Datasource is not configured; run datasource-configure --apply first")
1065
+ connection = state["connection"]
1066
+ namespace = connection["namespace"]
1067
+ tested = redact_connection_material(cli_json(
1068
+ install_state, runtime_state, namespace,
1069
+ ["datasources", "test", connection["name"]], "datasources test",
1070
+ ))
1071
+ if not args.bind:
1072
+ mark_step(state, "datasourceVerified", "waiting-for-binding", connectionTested=True)
1073
+ path = write_onboarding_state(data_root, state)
1074
+ return {
1075
+ "success": True,
1076
+ "schemaVersion": "foggy-deepseek-datasource-verify/v1",
1077
+ "profile": state["profile"],
1078
+ "connectionTested": True,
1079
+ "namespaceBound": False,
1080
+ "test": tested,
1081
+ "statePath": str(path),
1082
+ "next": "rerun datasource-verify --bind after approving namespace binding",
1083
+ "productionReady": False,
1084
+ }
1085
+ if connection.get("connectionMode") == "opaque-profile":
1086
+ bind_command = [
1087
+ "profiles", "apply", connection["opaqueProfileId"],
1088
+ "--approve-revision", connection["opaqueRevision"],
1089
+ "--approve-bind",
1090
+ ]
1091
+ bind_label = "opaque profile bind"
1092
+ else:
1093
+ bind_command = ["datasources", "bind", "--namespace", namespace, "--data-source", connection["name"]]
1094
+ bind_label = "datasources bind"
1095
+ bound = redact_connection_material(cli_json(
1096
+ install_state, runtime_state, namespace, bind_command, bind_label,
1097
+ ))
1098
+ binding = redact_connection_material(cli_json(
1099
+ install_state, runtime_state, namespace,
1100
+ ["datasources", "binding", "--namespace", namespace], "datasources binding",
1101
+ ))
1102
+ diagnostics = redact_connection_material(cli_json(
1103
+ install_state, runtime_state, namespace,
1104
+ ["datasources", "diagnostics"], "datasources diagnostics",
1105
+ ))
1106
+ mark_step(state, "datasourceVerified", "completed", connectionTested=True, namespaceBound=True)
1107
+ evidence_dir = data_root / "onboarding" / "evidence" / state["profile"] / f"datasource-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
1108
+ atomic_json(evidence_dir / "test.json", tested)
1109
+ atomic_json(evidence_dir / "bind.json", bound)
1110
+ atomic_json(evidence_dir / "binding.json", binding)
1111
+ atomic_json(evidence_dir / "diagnostics.json", diagnostics)
1112
+ state.setdefault("artifacts", {})["datasourceEvidence"] = str(evidence_dir)
1113
+ path = write_onboarding_state(data_root, state)
1114
+ return {
1115
+ "success": True,
1116
+ "schemaVersion": "foggy-deepseek-datasource-verify/v1",
1117
+ "profile": state["profile"],
1118
+ "connectionTested": True,
1119
+ "namespaceBound": True,
1120
+ "test": tested,
1121
+ "binding": binding,
1122
+ "diagnostics": diagnostics,
1123
+ "evidenceDir": str(evidence_dir),
1124
+ "statePath": str(path),
1125
+ "next": "run schema-discover",
1126
+ "productionReady": False,
1127
+ }
1128
+
1129
+
1130
+ def listed_tables(payload: dict) -> list[dict]:
1131
+ data = payload.get("data")
1132
+ tables = data.get("tables") if isinstance(data, dict) else None
1133
+ if not isinstance(tables, list) or any(not isinstance(item, dict) for item in tables):
1134
+ raise OnboardingError("tables list returned an unexpected data.tables shape")
1135
+ return tables
1136
+
1137
+
1138
+ def schema_discover_command(args: argparse.Namespace) -> dict:
1139
+ if args.max_tables < 1 or args.max_tables > 500:
1140
+ raise OnboardingError("--max-tables must be between 1 and 500")
1141
+ state, install_state, data_root, runtime_state = require_profile(args, require_runtime=True)
1142
+ if state["steps"]["datasourceVerified"]["status"] != "completed":
1143
+ raise OnboardingError("Datasource is not verified and bound; run datasource-verify --bind first")
1144
+ connection = state["connection"]
1145
+ namespace = connection["namespace"]
1146
+ schemas = args.schema or connection.get("schemas") or [None]
1147
+ list_results: list[dict] = []
1148
+ candidates: list[dict] = []
1149
+ for schema in schemas:
1150
+ command = ["tables", "list", "--data-source", connection["name"]]
1151
+ if schema:
1152
+ command.extend(["--schema", schema])
1153
+ if args.pattern:
1154
+ command.extend(["--pattern", args.pattern])
1155
+ if args.no_views:
1156
+ command.append("--no-views")
1157
+ result = cli_json(install_state, runtime_state, namespace, command, f"tables list ({schema or 'default schema'})")
1158
+ list_results.append(result)
1159
+ candidates.extend(listed_tables(result))
1160
+ unique: dict[tuple[str, str], dict] = {}
1161
+ for item in candidates:
1162
+ name = item.get("name")
1163
+ if isinstance(name, str) and name:
1164
+ unique[(str(item.get("schema") or ""), name)] = item
1165
+ tables = list(unique.values())
1166
+ requested = set(args.table or [])
1167
+ if requested:
1168
+ selected = [item for item in tables if item["name"] in requested or f"{item.get('schema')}.{item['name']}" in requested]
1169
+ matched = {item["name"] for item in selected} | {f"{item.get('schema')}.{item['name']}" for item in selected}
1170
+ missing = sorted(name for name in requested if name not in matched)
1171
+ if missing:
1172
+ raise OnboardingError(f"Requested tables were not returned by discovery: {', '.join(missing)}")
1173
+ else:
1174
+ selected = tables[:args.max_tables]
1175
+ inspections: list[dict] = []
1176
+ if not args.list_only:
1177
+ for item in selected:
1178
+ command = ["tables", "inspect", "--data-source", connection["name"], "--table", item["name"], "--include-foreign-keys"]
1179
+ if item.get("schema"):
1180
+ command.extend(["--schema", str(item["schema"])])
1181
+ if args.include_indexes:
1182
+ command.append("--include-indexes")
1183
+ inspections.append(cli_json(install_state, runtime_state, namespace, command, f"tables inspect ({item['name']})"))
1184
+ artifact = {
1185
+ "schemaVersion": "foggy-deepseek-schema-discovery/v1",
1186
+ "createdAt": now_utc(),
1187
+ "profile": state["profile"],
1188
+ "dataSource": connection["name"],
1189
+ "namespace": namespace,
1190
+ "schemas": schemas,
1191
+ "tableCount": len(tables),
1192
+ "selectedCount": len(selected),
1193
+ "truncated": not requested and len(tables) > len(selected),
1194
+ "lists": list_results,
1195
+ "inspections": inspections,
1196
+ }
1197
+ evidence_dir = data_root / "onboarding" / "evidence" / state["profile"]
1198
+ artifact_path = evidence_dir / f"schema-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
1199
+ atomic_json(artifact_path, artifact)
1200
+ mark_step(state, "schemaDiscovered", "completed", tableCount=len(tables), selectedCount=len(selected), listOnly=args.list_only)
1201
+ state.setdefault("artifacts", {})["schemaDiscovery"] = str(artifact_path)
1202
+ path = write_onboarding_state(data_root, state)
1203
+ return {
1204
+ "success": True,
1205
+ **artifact,
1206
+ "artifactPath": str(artifact_path),
1207
+ "statePath": str(path),
1208
+ "next": "review discovered metadata, then create a semantic draft",
1209
+ "productionReady": False,
1210
+ }
1211
+
1212
+
1213
+ def semantic_draft_command(args: argparse.Namespace) -> dict:
1214
+ state, _install_state, data_root, _runtime_state = require_profile(args, require_runtime=False)
1215
+ if state["steps"]["schemaDiscovered"]["status"] != "completed":
1216
+ raise OnboardingError("Schema discovery is not complete; run schema-discover before drafting TM/QM")
1217
+ plan = validate_semantic_plan(read_json_object(normalized(args.semantic_plan), "Semantic plan"))
1218
+ if plan.get("profile") is not None and plan["profile"] != state["profile"]:
1219
+ raise OnboardingError("semanticPlan.profile does not match the onboarding state profile")
1220
+ project_root = normalized(state["projectRoot"])
1221
+ models_dir = normalized(state["semantic"]["modelsDir"])
1222
+ draft_dir = normalized(project_root / plan["draftDir"])
1223
+ if not is_child(draft_dir, project_root) or draft_dir == project_root:
1224
+ raise OnboardingError("draftDir must stay inside projectRoot and cannot equal it")
1225
+ if is_child(draft_dir, models_dir) or is_child(models_dir, draft_dir):
1226
+ raise OnboardingError("draftDir and modelsDir must be separate, non-nested directories")
1227
+ manifest = semantic_manifest(draft_dir)
1228
+ qm_text = "\n".join(item.read_text(encoding="utf-8-sig") for item in semantic_files(draft_dir) if item.suffix.lower() == ".qm")
1229
+ missing_models = [
1230
+ name for name in plan["queryModels"]
1231
+ if not re.search(rf"\bname\s*:\s*['\"]{re.escape(name)}['\"]", qm_text)
1232
+ ]
1233
+ if missing_models:
1234
+ raise OnboardingError(f"Declared query models were not found in .qm files: {', '.join(missing_models)}")
1235
+ state["semantic"].update({
1236
+ "draftDir": str(draft_dir),
1237
+ "bundleName": plan["bundleName"],
1238
+ "queryModels": plan["queryModels"],
1239
+ "draftManifest": manifest,
1240
+ })
1241
+ mark_step(state, "semanticDrafted", "completed", digest=manifest["digest"], tmCount=manifest["tmCount"], qmCount=manifest["qmCount"])
1242
+ for later in ("semanticValidated", "semanticPublished", "semanticVerified"):
1243
+ state["steps"][later] = step("pending")
1244
+ evidence_dir = data_root / "onboarding" / "evidence" / state["profile"]
1245
+ artifact_path = evidence_dir / f"semantic-draft-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
1246
+ atomic_json(artifact_path, {"schemaVersion": "foggy-deepseek-semantic-draft/v1", "createdAt": now_utc(), **plan, "manifest": manifest})
1247
+ state.setdefault("artifacts", {})["semanticDraft"] = str(artifact_path)
1248
+ path = write_onboarding_state(data_root, state)
1249
+ return {
1250
+ "success": True,
1251
+ "schemaVersion": "foggy-deepseek-semantic-draft/v1",
1252
+ "profile": state["profile"],
1253
+ "draftDir": str(draft_dir),
1254
+ "modelsDir": str(models_dir),
1255
+ "bundleName": plan["bundleName"],
1256
+ "queryModels": plan["queryModels"],
1257
+ "manifest": manifest,
1258
+ "artifactPath": str(artifact_path),
1259
+ "statePath": str(path),
1260
+ "next": "run semantic-validate without --apply to review, then approve --apply",
1261
+ "productionReady": False,
1262
+ }
1263
+
1264
+
1265
+ def validation_passed(payload: dict) -> bool:
1266
+ data = payload.get("data")
1267
+ return bool(
1268
+ isinstance(data, dict)
1269
+ and data.get("valid") is True
1270
+ and isinstance(data.get("totalFiles"), int)
1271
+ and data["totalFiles"] > 0
1272
+ and data.get("validFiles") == data["totalFiles"]
1273
+ and data.get("invalidFiles") == 0
1274
+ )
1275
+
1276
+
1277
+ def validation_summary(payload: dict) -> dict:
1278
+ data = payload.get("data")
1279
+ if not isinstance(data, dict):
1280
+ return {"valid": False, "totalFiles": None, "validFiles": None, "invalidFiles": None, "warningCount": None}
1281
+ warnings = data.get("warnings")
1282
+ return {
1283
+ "valid": data.get("valid") is True,
1284
+ "totalFiles": data.get("totalFiles"),
1285
+ "validFiles": data.get("validFiles"),
1286
+ "invalidFiles": data.get("invalidFiles"),
1287
+ "cascadingErrors": data.get("cascadingErrors"),
1288
+ "warningCount": len(warnings) if isinstance(warnings, list) else None,
1289
+ }
1290
+
1291
+
1292
+ def semantic_validate_command(args: argparse.Namespace) -> dict:
1293
+ state, install_state, data_root, runtime_state = require_profile(args, require_runtime=args.apply)
1294
+ if state["steps"]["semanticDrafted"]["status"] != "completed":
1295
+ raise OnboardingError("Semantic draft is not registered; run semantic-draft first")
1296
+ draft_dir, manifest = require_unchanged_semantic_draft(state)
1297
+ plan = {
1298
+ "operation": "models validate",
1299
+ "modelsDir": str(draft_dir),
1300
+ "namespace": state["connection"]["namespace"],
1301
+ "draftDigest": manifest["digest"],
1302
+ "includeStackTrace": args.include_stack_trace,
1303
+ "runtimeMutation": "Runtime validation catalog may be replaced",
1304
+ }
1305
+ if not args.apply:
1306
+ return {"success": True, "dryRun": True, "profile": state["profile"], "plan": plan, "next": "rerun with --apply after approval"}
1307
+ command = ["models", "validate", "--models-dir", str(draft_dir)]
1308
+ if args.include_stack_trace:
1309
+ command.append("--include-stack-trace")
1310
+ result = cli_json(install_state, runtime_state, state["connection"]["namespace"], command, "models validate", timeout=180)
1311
+ evidence_dir = data_root / "onboarding" / "evidence" / state["profile"]
1312
+ artifact_path = evidence_dir / f"semantic-validate-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
1313
+ atomic_json(artifact_path, result)
1314
+ if not validation_passed(result):
1315
+ mark_step(state, "semanticValidated", "failed", digest=manifest["digest"], evidence=str(artifact_path))
1316
+ write_onboarding_state(data_root, state)
1317
+ raise OnboardingError(f"Semantic validation did not pass; inspect evidence: {artifact_path}")
1318
+ mark_step(state, "semanticValidated", "completed", digest=manifest["digest"], evidence=str(artifact_path))
1319
+ state.setdefault("artifacts", {})["semanticValidation"] = str(artifact_path)
1320
+ path = write_onboarding_state(data_root, state)
1321
+ return {
1322
+ "success": True,
1323
+ "schemaVersion": "foggy-deepseek-semantic-validate/v1",
1324
+ "profile": state["profile"],
1325
+ "valid": True,
1326
+ "validation": validation_summary(result),
1327
+ "artifactPath": str(artifact_path),
1328
+ "statePath": str(path),
1329
+ "next": "run semantic-publish without --apply to review the file and Runtime mutations",
1330
+ "productionReady": False,
1331
+ }
1332
+
1333
+
1334
+ def semantic_publish_command(args: argparse.Namespace) -> dict:
1335
+ state, install_state, data_root, runtime_state = require_profile(args, require_runtime=args.apply)
1336
+ if state["steps"]["semanticValidated"]["status"] != "completed":
1337
+ raise OnboardingError("Semantic draft has not passed validation")
1338
+ draft_dir, manifest = require_unchanged_semantic_draft(state)
1339
+ if state["steps"]["semanticValidated"].get("digest") != manifest["digest"]:
1340
+ raise OnboardingError("Semantic validation does not match the current draft")
1341
+ project_root = normalized(state["projectRoot"])
1342
+ models_dir = normalized(state["semantic"]["modelsDir"])
1343
+ if not is_child(models_dir, project_root) or models_dir == project_root or models_dir.is_symlink():
1344
+ raise OnboardingError("modelsDir must be a non-symlink child of projectRoot")
1345
+ diff = semantic_diff(manifest, models_dir, args.prune)
1346
+ plan = {
1347
+ "copy": diff,
1348
+ "modelsDir": str(models_dir),
1349
+ "bundleName": state["semantic"]["bundleName"],
1350
+ "queryModels": state["semantic"]["queryModels"],
1351
+ "replaceBundle": args.replace_bundle,
1352
+ "watch": args.watch,
1353
+ "operations": ["backup changed project model files", "copy draft TM/QM", "validate published directory", "register bundle", "refresh declared query models"],
1354
+ }
1355
+ if not args.apply:
1356
+ return {"success": True, "dryRun": True, "profile": state["profile"], "plan": plan, "next": "rerun with --apply after reviewing project and Runtime mutations"}
1357
+ backup_root = project_root / ".foggy" / "onboarding-backups" / state["profile"] / "semantic"
1358
+ backup = publish_files(draft_dir, models_dir, diff, backup_root)
1359
+ evidence_dir = data_root / "onboarding" / "evidence" / state["profile"] / f"semantic-publish-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
1360
+ namespace = state["connection"]["namespace"]
1361
+ try:
1362
+ published_validation = cli_json(
1363
+ install_state, runtime_state, namespace,
1364
+ ["models", "validate", "--models-dir", str(models_dir)], "published models validate", timeout=180,
1365
+ )
1366
+ atomic_json(evidence_dir / "validate.json", published_validation)
1367
+ if not validation_passed(published_validation):
1368
+ raise OnboardingError("Published model directory did not pass validation")
1369
+ bundle_command = ["bundles", "add", "--name", state["semantic"]["bundleName"], "--path", str(models_dir)]
1370
+ if args.watch:
1371
+ bundle_command.append("--watch")
1372
+ if args.replace_bundle:
1373
+ bundle_command.append("--replace")
1374
+ bundle = cli_json(install_state, runtime_state, namespace, bundle_command, "bundles add", timeout=60)
1375
+ atomic_json(evidence_dir / "bundle.json", bundle)
1376
+ except Exception as exc:
1377
+ rollback_published_files(backup)
1378
+ mark_step(state, "semanticPublished", "failed", rolledBack=True, backupDir=backup["backupDir"], error=str(exc))
1379
+ state.setdefault("artifacts", {})["semanticPublishEvidence"] = str(evidence_dir)
1380
+ write_onboarding_state(data_root, state)
1381
+ raise OnboardingError(f"Semantic publish failed before refresh; project files were rolled back: {exc}") from exc
1382
+ refresh_command = ["models", "refresh"]
1383
+ for model in state["semantic"]["queryModels"]:
1384
+ refresh_command.extend(["--model", model])
1385
+ try:
1386
+ refresh = cli_json(install_state, runtime_state, namespace, refresh_command, "models refresh", timeout=180)
1387
+ atomic_json(evidence_dir / "refresh.json", refresh)
1388
+ except Exception as exc:
1389
+ mark_step(state, "semanticPublished", "refresh-failed", bundleRegistered=True, backupDir=backup["backupDir"], error=str(exc))
1390
+ state.setdefault("artifacts", {})["semanticPublishEvidence"] = str(evidence_dir)
1391
+ write_onboarding_state(data_root, state)
1392
+ raise OnboardingError(f"Bundle was registered but model refresh failed; inspect evidence and do not republish blindly: {exc}") from exc
1393
+ mark_step(
1394
+ state, "semanticPublished", "completed", digest=manifest["digest"], bundleName=state["semantic"]["bundleName"],
1395
+ backupDir=backup["backupDir"], replaceBundle=args.replace_bundle, watch=args.watch,
1396
+ )
1397
+ state.setdefault("artifacts", {})["semanticPublishEvidence"] = str(evidence_dir)
1398
+ state["artifacts"]["semanticBackup"] = backup["backupDir"]
1399
+ path = write_onboarding_state(data_root, state)
1400
+ return {
1401
+ "success": True,
1402
+ "schemaVersion": "foggy-deepseek-semantic-publish/v1",
1403
+ "profile": state["profile"],
1404
+ "modelsDir": str(models_dir),
1405
+ "bundleName": state["semantic"]["bundleName"],
1406
+ "queryModels": state["semantic"]["queryModels"],
1407
+ "diff": diff,
1408
+ "backupDir": backup["backupDir"],
1409
+ "evidenceDir": str(evidence_dir),
1410
+ "statePath": str(path),
1411
+ "next": "prepare a bounded query payload and run semantic-verify without --execute, then approve --execute",
1412
+ "productionReady": False,
1413
+ }
1414
+
1415
+
1416
+ def bounded_query_payload(path: Path) -> dict:
1417
+ payload = read_json_object(path, "Query payload")
1418
+ limit = payload.get("limit")
1419
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 100:
1420
+ raise OnboardingError("Query smoke payload must set integer limit between 1 and 100")
1421
+ return payload
1422
+
1423
+
1424
+ def safe_evidence_name(value: str) -> str:
1425
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)[:128]
1426
+
1427
+
1428
+ def nested_data_objects(payload: dict) -> list[dict]:
1429
+ objects: list[dict] = []
1430
+ current: object = payload
1431
+ for _ in range(4):
1432
+ if not isinstance(current, dict):
1433
+ break
1434
+ objects.append(current)
1435
+ next_value = current.get("data")
1436
+ if not isinstance(next_value, dict) or next_value is current:
1437
+ break
1438
+ current = next_value
1439
+ return objects
1440
+
1441
+
1442
+ def response_field_count(payload: dict) -> int | None:
1443
+ for item in nested_data_objects(payload):
1444
+ fields = item.get("fields")
1445
+ if isinstance(fields, (list, dict)):
1446
+ return len(fields)
1447
+ return None
1448
+
1449
+
1450
+ def response_row_count(payload: dict) -> int | None:
1451
+ for item in nested_data_objects(payload):
1452
+ for key in ("items", "rows", "records"):
1453
+ rows = item.get(key)
1454
+ if isinstance(rows, list):
1455
+ return len(rows)
1456
+ return None
1457
+
1458
+
1459
+ def semantic_verify_command(args: argparse.Namespace) -> dict:
1460
+ state, install_state, data_root, runtime_state = require_profile(args, require_runtime=True)
1461
+ if state["steps"]["semanticPublished"]["status"] != "completed":
1462
+ raise OnboardingError("Semantic layer is not published and refreshed")
1463
+ declared_models = state["semantic"]["queryModels"]
1464
+ query_model = args.query_model or (declared_models[0] if len(declared_models) == 1 else None)
1465
+ if not query_model:
1466
+ raise OnboardingError("--query-model is required when the semantic plan declares multiple query models")
1467
+ if query_model not in declared_models:
1468
+ raise OnboardingError("--query-model must be declared in the registered semantic plan")
1469
+ if not args.query_payload:
1470
+ raise OnboardingError("--query-payload is required for semantic verification")
1471
+ project_root = normalized(state["projectRoot"])
1472
+ payload_path = normalized(args.query_payload)
1473
+ if not is_child(payload_path, project_root):
1474
+ raise OnboardingError("Query payload must stay inside projectRoot")
1475
+ bounded_query_payload(payload_path)
1476
+ namespace = state["connection"]["namespace"]
1477
+ evidence_dir = data_root / "onboarding" / "evidence" / state["profile"] / f"semantic-verify-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
1478
+ model_list = cli_json(install_state, runtime_state, namespace, ["models", "list"], "models list")
1479
+ atomic_json(evidence_dir / "models-list.json", model_list)
1480
+ descriptions = []
1481
+ for model in declared_models:
1482
+ description = cli_json(install_state, runtime_state, namespace, ["models", "describe", model], f"models describe ({model})", timeout=90)
1483
+ atomic_json(evidence_dir / f"describe-{safe_evidence_name(model)}.json", description)
1484
+ descriptions.append({"model": model, "fieldCount": response_field_count(description)})
1485
+ validated = cli_json(
1486
+ install_state, runtime_state, namespace,
1487
+ ["query", "validate", query_model, "--payload", str(payload_path)], "query validate", timeout=90,
1488
+ )
1489
+ atomic_json(evidence_dir / "query-validate.json", validated)
1490
+ if not args.execute:
1491
+ mark_step(state, "semanticVerified", "waiting-for-query-execution", queryModel=query_model, queryValidated=True)
1492
+ state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
1493
+ path = write_onboarding_state(data_root, state)
1494
+ return {
1495
+ "success": True,
1496
+ "schemaVersion": "foggy-deepseek-semantic-verify/v1",
1497
+ "profile": state["profile"],
1498
+ "models": descriptions,
1499
+ "queryModel": query_model,
1500
+ "queryValidated": True,
1501
+ "queryExecuted": False,
1502
+ "evidenceDir": str(evidence_dir),
1503
+ "statePath": str(path),
1504
+ "next": "rerun semantic-verify with --execute after approving the bounded business-data query",
1505
+ "productionReady": False,
1506
+ }
1507
+ executed = cli_json(
1508
+ install_state, runtime_state, namespace,
1509
+ ["query", "execute", query_model, "--payload", str(payload_path)], "query execute", timeout=120,
1510
+ )
1511
+ atomic_json(evidence_dir / "query-execute.json", executed)
1512
+ row_count = response_row_count(executed)
1513
+ mark_step(state, "semanticVerified", "completed", queryModel=query_model, queryValidated=True, queryExecuted=True)
1514
+ state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
1515
+ path = write_onboarding_state(data_root, state)
1516
+ return {
1517
+ "success": True,
1518
+ "schemaVersion": "foggy-deepseek-semantic-verify/v1",
1519
+ "profile": state["profile"],
1520
+ "models": descriptions,
1521
+ "queryModel": query_model,
1522
+ "queryValidated": True,
1523
+ "queryExecuted": True,
1524
+ "rowCount": row_count,
1525
+ "evidenceDir": str(evidence_dir),
1526
+ "statePath": str(path),
1527
+ "next": "onboarding complete; begin analysis with described field names",
1528
+ "productionReady": False,
1529
+ }
1530
+
1531
+
1532
+ def next_onboarding_action(state: dict) -> dict:
1533
+ ordered = [
1534
+ ("datasourceConfigured", "datasource-configure --apply"),
1535
+ ("datasourceVerified", "datasource-verify --bind"),
1536
+ ("schemaDiscovered", "schema-discover"),
1537
+ ("semanticDrafted", "semantic-draft --semantic-plan <json>"),
1538
+ ("semanticValidated", "semantic-validate --apply"),
1539
+ ("semanticPublished", "semantic-publish --apply"),
1540
+ ("semanticVerified", "semantic-verify --query-payload <json> --execute"),
1541
+ ]
1542
+ for name, command in ordered:
1543
+ current = state.get("steps", {}).get(name, {})
1544
+ if current.get("status") != "completed":
1545
+ if name == "semanticPublished" and current.get("status") == "refresh-failed":
1546
+ return {
1547
+ "step": name,
1548
+ "command": None,
1549
+ "status": "refresh-failed",
1550
+ "instruction": "Inspect semantic publish evidence and repair refresh before any republish attempt.",
1551
+ }
1552
+ if name == "semanticValidated" and current.get("status") == "failed":
1553
+ command = "repair TM/QM, then semantic-draft --semantic-plan <json>"
1554
+ return {"step": name, "command": command, "status": current.get("status", "pending")}
1555
+ return {"step": None, "command": None, "status": "completed"}
1556
+
1557
+
1558
+ def onboarding_status_command(args: argparse.Namespace) -> dict:
1559
+ state, _install_state, _data_root, runtime_state = require_profile(args, require_runtime=False)
1560
+ runtime_running = bool(runtime_state and process_info(int(runtime_state.get("pid", 0)))["running"])
1561
+ connection = state["connection"]
1562
+ password_env = connection.get("passwordEnv")
1563
+ return {
1564
+ "success": True,
1565
+ "schemaVersion": "foggy-deepseek-onboarding-status/v1",
1566
+ "profile": state["profile"],
1567
+ "runtimeRunning": runtime_running,
1568
+ "passwordEnv": password_env,
1569
+ "passwordEnvPresent": bool(password_env and os.environ.get(password_env)),
1570
+ "steps": state["steps"],
1571
+ "artifacts": state.get("artifacts", {}),
1572
+ "next": next_onboarding_action(state),
1573
+ "productionReady": False,
1574
+ }
1575
+
1576
+
1577
+ def onboarding_resume_command(args: argparse.Namespace) -> dict:
1578
+ result = onboarding_status_command(args)
1579
+ result["schemaVersion"] = "foggy-deepseek-onboarding-resume/v1"
1580
+ result["instruction"] = "Execute only the reported next command; this command does not mutate Runtime or project files."
1581
+ return result
1582
+
1583
+
1584
+ def composite_evidence_dir(project_root: Path, profile: str, requested: str | None) -> Path:
1585
+ destination = normalized(requested) if requested else project_root / ".foggy" / "onboarding-command-evidence" / profile
1586
+ if not is_child(destination, project_root) or destination == project_root:
1587
+ raise OnboardingError("Composite evidence directory must stay below projectRoot")
1588
+ destination.mkdir(parents=True, exist_ok=True)
1589
+ return destination
1590
+
1591
+
1592
+ def save_composite_result(evidence_dir: Path, name: str, payload: dict, files: list[str]) -> None:
1593
+ path = evidence_dir / name
1594
+ atomic_json(path, payload)
1595
+ files.append(str(path))
1596
+
1597
+
1598
+ def datasource_run_command(args: argparse.Namespace) -> dict:
1599
+ _install_root, install_state, data_root, _runtime_state = onboarding_context(args, require_runtime=True)
1600
+ project_root = normalized(args.project_root or install_state["projectRoot"])
1601
+ requested_connection = validate_connection(read_json_object(normalized(args.connection_file), "Connection plan"))
1602
+ if not requested_connection.get("profile"):
1603
+ raise OnboardingError("Composite datasource onboarding requires connection.profile in the approved contract")
1604
+ profile = requested_connection["profile"]
1605
+ if args.profile is not None and safe_profile(args.profile) != profile:
1606
+ raise OnboardingError("Command profile conflicts with the approved connection.profile")
1607
+ contract_evidence = requested_connection.get("evidenceDir")
1608
+ if contract_evidence and args.evidence_dir:
1609
+ if normalized(project_root / contract_evidence) != normalized(args.evidence_dir):
1610
+ raise OnboardingError("Command evidence directory conflicts with the approved connection.evidenceDir")
1611
+ evidence_dir = composite_evidence_dir(project_root, profile, str(project_root / contract_evidence) if contract_evidence else args.evidence_dir)
1612
+ files: list[str] = []
1613
+ existing = read_onboarding_state(data_root, profile, required=False)
1614
+ if existing:
1615
+ if existing.get("connection") != requested_connection:
1616
+ raise OnboardingError("Existing onboarding profile does not match the requested connection plan")
1617
+ plan_result = {
1618
+ "success": True,
1619
+ "schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
1620
+ "profile": profile,
1621
+ "resumed": True,
1622
+ "statePath": str(onboarding_state_path(data_root, profile)),
1623
+ "next": next_onboarding_action(existing),
1624
+ "productionReady": False,
1625
+ }
1626
+ else:
1627
+ plan_result = onboarding_plan_command(argparse.Namespace(
1628
+ install_root=args.install_root,
1629
+ data_root=args.data_root,
1630
+ project_root=str(project_root),
1631
+ profile=profile,
1632
+ connection_file=args.connection_file,
1633
+ replace_plan=False,
1634
+ ))
1635
+ save_composite_result(evidence_dir, "01-plan.json", plan_result, files)
1636
+
1637
+ configure_dry = datasource_configure_command(argparse.Namespace(
1638
+ install_root=args.install_root, data_root=args.data_root, profile=profile, apply=False, replace=False,
1639
+ ))
1640
+ save_composite_result(evidence_dir, "02-datasource-dry.json", configure_dry, files)
1641
+ if not args.approve_configure:
1642
+ return {
1643
+ "success": True,
1644
+ "schemaVersion": "foggy-deepseek-datasource-run/v1",
1645
+ "profile": profile,
1646
+ "phaseStatus": "awaiting-configure-approval",
1647
+ "evidenceDir": str(evidence_dir),
1648
+ "evidenceFiles": files,
1649
+ "next": "rerun with --approve-configure after the datasource mutation is approved",
1650
+ "productionReady": False,
1651
+ }
1652
+
1653
+ configured = datasource_configure_command(argparse.Namespace(
1654
+ install_root=args.install_root, data_root=args.data_root, profile=profile, apply=True, replace=False,
1655
+ ))
1656
+ save_composite_result(evidence_dir, "03-datasource-apply.json", configured, files)
1657
+ tested = datasource_verify_command(argparse.Namespace(
1658
+ install_root=args.install_root, data_root=args.data_root, profile=profile, bind=False,
1659
+ ))
1660
+ save_composite_result(evidence_dir, "04-datasource-test.json", tested, files)
1661
+ if not args.approve_bind:
1662
+ return {
1663
+ "success": True,
1664
+ "schemaVersion": "foggy-deepseek-datasource-run/v1",
1665
+ "profile": profile,
1666
+ "phaseStatus": "awaiting-bind-approval",
1667
+ "evidenceDir": str(evidence_dir),
1668
+ "evidenceFiles": files,
1669
+ "next": "rerun with --approve-configure --approve-bind after namespace binding is approved",
1670
+ "productionReady": False,
1671
+ }
1672
+
1673
+ bound = datasource_verify_command(argparse.Namespace(
1674
+ install_root=args.install_root, data_root=args.data_root, profile=profile, bind=True,
1675
+ ))
1676
+ save_composite_result(evidence_dir, "05-datasource-bind.json", bound, files)
1677
+ discovered = schema_discover_command(argparse.Namespace(
1678
+ install_root=args.install_root,
1679
+ data_root=args.data_root,
1680
+ profile=profile,
1681
+ schema=args.schema,
1682
+ pattern=args.pattern,
1683
+ table=args.table,
1684
+ max_tables=args.max_tables,
1685
+ list_only=False,
1686
+ no_views=args.no_views,
1687
+ include_indexes=args.include_indexes,
1688
+ ))
1689
+ save_composite_result(evidence_dir, "06-schema.json", discovered, files)
1690
+ return {
1691
+ "success": True,
1692
+ "schemaVersion": "foggy-deepseek-datasource-run/v1",
1693
+ "profile": profile,
1694
+ "phaseStatus": "completed",
1695
+ "selectedTableCount": discovered["selectedCount"],
1696
+ "schemaArtifactPath": discovered["artifactPath"],
1697
+ "evidenceDir": str(evidence_dir),
1698
+ "evidenceFiles": files,
1699
+ "next": "author TM/QM drafts from schema metadata and confirmed business definitions",
1700
+ "productionReady": False,
1701
+ }
1702
+
1703
+
1704
+ def semantic_run_command(args: argparse.Namespace) -> dict:
1705
+ approved_plan = validate_semantic_plan(read_json_object(normalized(args.semantic_plan), "Semantic plan"))
1706
+ if not approved_plan.get("profile"):
1707
+ raise OnboardingError("Composite semantic onboarding requires semanticPlan.profile in the approved contract")
1708
+ profile = approved_plan["profile"]
1709
+ if args.profile is not None and safe_profile(args.profile) != profile:
1710
+ raise OnboardingError("Command profile conflicts with the approved semanticPlan.profile")
1711
+ profile_args = argparse.Namespace(**vars(args))
1712
+ profile_args.profile = profile
1713
+ state, _install_state, _data_root, _runtime_state = require_profile(profile_args, require_runtime=True)
1714
+ project_root = normalized(state["projectRoot"])
1715
+ contract_evidence = approved_plan.get("evidenceDir")
1716
+ if contract_evidence and args.evidence_dir:
1717
+ if normalized(project_root / contract_evidence) != normalized(args.evidence_dir):
1718
+ raise OnboardingError("Command evidence directory conflicts with the approved semanticPlan.evidenceDir")
1719
+ evidence_dir = composite_evidence_dir(project_root, profile, str(project_root / contract_evidence) if contract_evidence else args.evidence_dir)
1720
+ files: list[str] = []
1721
+
1722
+ drafted = semantic_draft_command(argparse.Namespace(
1723
+ install_root=args.install_root,
1724
+ data_root=args.data_root,
1725
+ profile=profile,
1726
+ semantic_plan=args.semantic_plan,
1727
+ ))
1728
+ save_composite_result(evidence_dir, "07-semantic-draft.json", drafted, files)
1729
+ validate_dry = semantic_validate_command(argparse.Namespace(
1730
+ install_root=args.install_root,
1731
+ data_root=args.data_root,
1732
+ profile=profile,
1733
+ apply=False,
1734
+ include_stack_trace=False,
1735
+ ))
1736
+ save_composite_result(evidence_dir, "08-semantic-validate-dry.json", validate_dry, files)
1737
+ if not args.approve_validate:
1738
+ return {
1739
+ "success": True,
1740
+ "schemaVersion": "foggy-deepseek-semantic-run/v1",
1741
+ "profile": profile,
1742
+ "phaseStatus": "awaiting-validate-approval",
1743
+ "evidenceDir": str(evidence_dir),
1744
+ "evidenceFiles": files,
1745
+ "next": "rerun with --approve-validate after the validation catalog mutation is approved",
1746
+ "productionReady": False,
1747
+ }
1748
+
1749
+ validated = semantic_validate_command(argparse.Namespace(
1750
+ install_root=args.install_root,
1751
+ data_root=args.data_root,
1752
+ profile=profile,
1753
+ apply=True,
1754
+ include_stack_trace=False,
1755
+ ))
1756
+ save_composite_result(evidence_dir, "09-semantic-validate-apply.json", validated, files)
1757
+ publish_dry = semantic_publish_command(argparse.Namespace(
1758
+ install_root=args.install_root,
1759
+ data_root=args.data_root,
1760
+ profile=profile,
1761
+ apply=False,
1762
+ replace_bundle=False,
1763
+ watch=False,
1764
+ prune=False,
1765
+ ))
1766
+ save_composite_result(evidence_dir, "10-semantic-publish-dry.json", publish_dry, files)
1767
+ if not args.approve_publish:
1768
+ return {
1769
+ "success": True,
1770
+ "schemaVersion": "foggy-deepseek-semantic-run/v1",
1771
+ "profile": profile,
1772
+ "phaseStatus": "awaiting-publish-approval",
1773
+ "evidenceDir": str(evidence_dir),
1774
+ "evidenceFiles": files,
1775
+ "next": "rerun with --approve-validate --approve-publish after publication is approved",
1776
+ "productionReady": False,
1777
+ }
1778
+
1779
+ published = semantic_publish_command(argparse.Namespace(
1780
+ install_root=args.install_root,
1781
+ data_root=args.data_root,
1782
+ profile=profile,
1783
+ apply=True,
1784
+ replace_bundle=False,
1785
+ watch=False,
1786
+ prune=False,
1787
+ ))
1788
+ save_composite_result(evidence_dir, "11-semantic-publish-apply.json", published, files)
1789
+ query_validated = semantic_verify_command(argparse.Namespace(
1790
+ install_root=args.install_root,
1791
+ data_root=args.data_root,
1792
+ profile=profile,
1793
+ query_model=args.query_model,
1794
+ query_payload=args.query_payload,
1795
+ execute=False,
1796
+ ))
1797
+ save_composite_result(evidence_dir, "12-query-validate.json", query_validated, files)
1798
+ if not args.approve_execute:
1799
+ return {
1800
+ "success": True,
1801
+ "schemaVersion": "foggy-deepseek-semantic-run/v1",
1802
+ "profile": profile,
1803
+ "phaseStatus": "awaiting-query-execution-approval",
1804
+ "queryValidated": True,
1805
+ "queryExecuted": False,
1806
+ "evidenceDir": str(evidence_dir),
1807
+ "evidenceFiles": files,
1808
+ "next": "rerun with all three approval flags after bounded query execution is approved",
1809
+ "productionReady": False,
1810
+ }
1811
+
1812
+ executed = semantic_verify_command(argparse.Namespace(
1813
+ install_root=args.install_root,
1814
+ data_root=args.data_root,
1815
+ profile=profile,
1816
+ query_model=args.query_model,
1817
+ query_payload=args.query_payload,
1818
+ execute=True,
1819
+ ))
1820
+ save_composite_result(evidence_dir, "13-query-execute.json", executed, files)
1821
+ status = onboarding_status_command(argparse.Namespace(
1822
+ install_root=args.install_root, data_root=args.data_root, profile=profile,
1823
+ ))
1824
+ save_composite_result(evidence_dir, "14-status.json", status, files)
1825
+ if status["next"].get("status") != "completed":
1826
+ raise OnboardingError("Composite semantic run ended with an incomplete persisted onboarding state")
1827
+ return {
1828
+ "success": True,
1829
+ "schemaVersion": "foggy-deepseek-semantic-run/v1",
1830
+ "profile": profile,
1831
+ "phaseStatus": "completed",
1832
+ "queryModel": executed["queryModel"],
1833
+ "queryValidated": executed["queryValidated"],
1834
+ "queryExecuted": executed["queryExecuted"],
1835
+ "rowCount": executed["rowCount"],
1836
+ "evidenceDir": str(evidence_dir),
1837
+ "evidenceFiles": files,
1838
+ "productionReady": False,
1839
+ }
1840
+
1841
+
1842
+ def doctor_command(args: argparse.Namespace) -> dict:
1843
+ versions = load_versions()
1844
+ install_root = normalized(args.install_root or default_install_root())
1845
+ project_root = normalized(args.project_root or Path.cwd())
1846
+ state = read_install_state(install_root, required=False)
1847
+ python_ok = sys.version_info >= (3, 11)
1848
+ java = java_probe()
1849
+ java_ok = java["available"] and java["exitCode"] == 0 and version_tuple(java["version"] or "") >= (17, 0, 0)
1850
+ cli_command = state.get("cli", {}).get("command") if state else shutil.which("foggy-runtime")
1851
+ cli = command_result([cli_command, "--version"]) if cli_command else {"available": False, "exitCode": None, "stdout": "", "stderr": "command not found", "durationMs": 0}
1852
+ cli_ok = cli["available"] and cli["exitCode"] == 0 and version_tuple(cli["stdout"]) >= version_tuple(versions["components"]["analysisSkill"]["minimumCliVersion"])
1853
+ launcher_checks = []
1854
+ if state:
1855
+ launcher_dir = normalized(state["launcher"]["path"])
1856
+ for asset in versions["components"]["launcher"]["assets"]:
1857
+ path = launcher_dir / asset["file"]
1858
+ launcher_checks.append({"file": asset["file"], "present": path.is_file(), "sha256Valid": path.is_file() and sha256(path) == asset["sha256"]})
1859
+ launcher_ok = bool(launcher_checks) and all(item["present"] and item["sha256Valid"] for item in launcher_checks)
1860
+ analysis_skill = project_root / ".agents" / "skills" / "foggy-ai-analysis" / "SKILL.md"
1861
+ onboarding_skill = project_root / ".agents" / "skills" / "foggy-deepseek-onboarding" / "SKILL.md"
1862
+ runtime = {"status": "stopped"}
1863
+ if state:
1864
+ data_root = normalized(state["dataRoot"])
1865
+ runtime_state_path = data_root / "runtime-state.json"
1866
+ if runtime_state_path.is_file():
1867
+ runtime_state = json.loads(runtime_state_path.read_text(encoding="utf-8"))
1868
+ info = process_info(int(runtime_state.get("pid", 0)))
1869
+ runtime = {"status": "running" if info["running"] else "stale", "pid": runtime_state.get("pid"), "runtimeUrl": runtime_state.get("runtimeUrl"), "identity": runtime_state.get("identity")}
1870
+ required = {
1871
+ "python": python_ok,
1872
+ "java": java_ok,
1873
+ "cli": cli_ok,
1874
+ "launcher": launcher_ok,
1875
+ "analysisSkill": analysis_skill.is_file(),
1876
+ }
1877
+ if args.strict_runtime:
1878
+ required["runtime"] = runtime["status"] == "running"
1879
+ result = {
1880
+ "success": all(required.values()),
1881
+ "schemaVersion": "foggy-deepseek-onboarding-doctor/v1",
1882
+ "installRoot": str(install_root),
1883
+ "projectRoot": str(project_root),
1884
+ "checks": required,
1885
+ "python": {"version": sys.version.split()[0], "minimum": versions["components"]["cli"]["minimumPythonVersion"]},
1886
+ "java": java,
1887
+ "cli": cli,
1888
+ "launcherAssets": launcher_checks,
1889
+ "skills": {"analysis": str(analysis_skill), "onboarding": str(onboarding_skill), "onboardingPresent": onboarding_skill.is_file()},
1890
+ "runtime": runtime,
1891
+ "environmentPresence": {name: bool(os.environ.get(name)) for name in ("DEEPSEEK_API_KEY", "ALIYUN_TOKEN_PLAN_API_KEY", "FOGGY_RUNTIME_API_AUTH_CODE", "FOGGY_RUNTIME_AUTHORIZATION")},
1892
+ "productionReady": False,
1893
+ }
1894
+ return result
1895
+
1896
+
1897
+ def uninstall_command(args: argparse.Namespace) -> dict:
1898
+ install_root = normalized(args.install_root or default_install_root())
1899
+ state = read_install_state(install_root)
1900
+ data_root = normalized(state["dataRoot"])
1901
+ assert_managed_root(install_root, "Install root")
1902
+ assert_managed_root(data_root, "Data root")
1903
+ if install_root == data_root:
1904
+ raise OnboardingError("Install root and data root must be different")
1905
+ project_root = normalized(state["projectRoot"])
1906
+ skills_root = project_root / ".agents" / "skills"
1907
+ skill_targets = [skills_root / name for name in ("foggy-deepseek-onboarding", "foggy-ai-analysis")]
1908
+ if args.remove_skills:
1909
+ for target in skill_targets:
1910
+ if target.exists() and (target.is_symlink() or not is_child(target, skills_root)):
1911
+ raise OnboardingError(f"Refusing to remove unexpected Skill path: {target}")
1912
+ plan = {"installRoot": str(install_root), "dataRoot": str(data_root), "removeSkills": args.remove_skills, "purgeData": args.purge_data}
1913
+ if args.dry_run:
1914
+ return {"success": True, "dryRun": True, "plan": plan}
1915
+ if not args.yes:
1916
+ raise OnboardingError("Uninstall requires --yes; use --dry-run to inspect the plan")
1917
+ stopped = stop_recorded_runtime(data_root, force=args.force)
1918
+ removed_skills = []
1919
+ if args.remove_skills:
1920
+ for target in skill_targets:
1921
+ if target.exists():
1922
+ shutil.rmtree(target)
1923
+ removed_skills.append(str(target))
1924
+ if not (install_root / "install-state.json").is_file():
1925
+ raise OnboardingError(f"Refusing to remove unsafe install root: {install_root}")
1926
+ shutil.rmtree(install_root)
1927
+ data_removed = False
1928
+ if args.purge_data and data_root.exists():
1929
+ shutil.rmtree(data_root)
1930
+ data_removed = True
1931
+ return {"success": True, "schemaVersion": "foggy-deepseek-onboarding-uninstall/v1", "stopped": stopped, "removedInstallRoot": str(install_root), "removedSkills": removed_skills, "dataRemoved": data_removed, "preservedDataRoot": None if data_removed else str(data_root)}
1932
+
1933
+
1934
+ def build_parser() -> argparse.ArgumentParser:
1935
+ parser = argparse.ArgumentParser(description=__doc__)
1936
+ sub = parser.add_subparsers(dest="command", required=True)
1937
+ manifest = sub.add_parser("manifest")
1938
+ manifest.set_defaults(handler=lambda _args: {"success": True, "versions": load_versions()})
1939
+
1940
+ install = sub.add_parser("install")
1941
+ install.add_argument("--install-root")
1942
+ install.add_argument("--data-root")
1943
+ install.add_argument("--project-root")
1944
+ install.add_argument("--asset-cache-dir", action="append", default=[])
1945
+ install.add_argument("--replace-skill", action="store_true")
1946
+ install.add_argument("--skip-cli-install", action="store_true")
1947
+ install.add_argument("--cli-command")
1948
+ install.add_argument("--dry-run", action="store_true")
1949
+ install.set_defaults(handler=install_command)
1950
+
1951
+ doctor = sub.add_parser("doctor")
1952
+ doctor.add_argument("--install-root")
1953
+ doctor.add_argument("--project-root")
1954
+ doctor.add_argument("--strict-runtime", action="store_true")
1955
+ doctor.add_argument("--no-fail", action="store_true")
1956
+ doctor.set_defaults(handler=doctor_command)
1957
+
1958
+ start = sub.add_parser("runtime-start")
1959
+ start.add_argument("--install-root")
1960
+ start.add_argument("--data-root")
1961
+ start.add_argument("--project-root")
1962
+ start.add_argument("--port", type=int)
1963
+ start.add_argument("--namespace")
1964
+ start.add_argument("--timeout", type=int)
1965
+ start.add_argument("--java")
1966
+ start.set_defaults(handler=runtime_start_command)
1967
+
1968
+ stop = sub.add_parser("runtime-stop")
1969
+ stop.add_argument("--install-root")
1970
+ stop.add_argument("--data-root")
1971
+ stop.add_argument("--force", action="store_true")
1972
+ stop.set_defaults(handler=lambda args: stop_recorded_runtime(normalized(args.data_root or read_install_state(normalized(args.install_root or default_install_root()))["dataRoot"]), force=args.force))
1973
+
1974
+ plan = sub.add_parser("onboard-plan")
1975
+ plan.add_argument("--install-root")
1976
+ plan.add_argument("--data-root")
1977
+ plan.add_argument("--project-root")
1978
+ plan.add_argument("--profile", default="default")
1979
+ plan.add_argument("--connection-file", required=True)
1980
+ plan.add_argument("--replace-plan", action="store_true")
1981
+ plan.set_defaults(handler=onboarding_plan_command)
1982
+
1983
+ status = sub.add_parser("onboard-status")
1984
+ status.add_argument("--install-root")
1985
+ status.add_argument("--data-root")
1986
+ status.add_argument("--profile", default="default")
1987
+ status.set_defaults(handler=onboarding_status_command)
1988
+
1989
+ resume = sub.add_parser("onboard-resume")
1990
+ resume.add_argument("--install-root")
1991
+ resume.add_argument("--data-root")
1992
+ resume.add_argument("--profile", default="default")
1993
+ resume.set_defaults(handler=onboarding_resume_command)
1994
+
1995
+ datasource_run = sub.add_parser("onboard-datasource-run")
1996
+ datasource_run.add_argument("--install-root")
1997
+ datasource_run.add_argument("--data-root")
1998
+ datasource_run.add_argument("--project-root")
1999
+ datasource_run.add_argument("--profile")
2000
+ datasource_run.add_argument("--connection-file", required=True)
2001
+ datasource_run.add_argument("--evidence-dir")
2002
+ datasource_run.add_argument("--approve-configure", action="store_true")
2003
+ datasource_run.add_argument("--approve-bind", action="store_true")
2004
+ datasource_run.add_argument("--schema", action="append")
2005
+ datasource_run.add_argument("--pattern")
2006
+ datasource_run.add_argument("--table", action="append")
2007
+ datasource_run.add_argument("--max-tables", type=int, default=25)
2008
+ datasource_run.add_argument("--no-views", action="store_true")
2009
+ datasource_run.add_argument("--include-indexes", action="store_true")
2010
+ datasource_run.set_defaults(handler=datasource_run_command)
2011
+
2012
+ semantic_run = sub.add_parser("onboard-semantic-run")
2013
+ semantic_run.add_argument("--install-root")
2014
+ semantic_run.add_argument("--data-root")
2015
+ semantic_run.add_argument("--profile")
2016
+ semantic_run.add_argument("--semantic-plan", required=True)
2017
+ semantic_run.add_argument("--query-payload", required=True)
2018
+ semantic_run.add_argument("--query-model")
2019
+ semantic_run.add_argument("--evidence-dir")
2020
+ semantic_run.add_argument("--approve-validate", action="store_true")
2021
+ semantic_run.add_argument("--approve-publish", action="store_true")
2022
+ semantic_run.add_argument("--approve-execute", action="store_true")
2023
+ semantic_run.set_defaults(handler=semantic_run_command)
2024
+
2025
+ datasource_configure = sub.add_parser("datasource-configure")
2026
+ datasource_configure.add_argument("--install-root")
2027
+ datasource_configure.add_argument("--data-root")
2028
+ datasource_configure.add_argument("--profile", default="default")
2029
+ datasource_configure.add_argument("--apply", action="store_true")
2030
+ datasource_configure.add_argument("--replace", action="store_true")
2031
+ datasource_configure.set_defaults(handler=datasource_configure_command)
2032
+
2033
+ datasource_verify = sub.add_parser("datasource-verify")
2034
+ datasource_verify.add_argument("--install-root")
2035
+ datasource_verify.add_argument("--data-root")
2036
+ datasource_verify.add_argument("--profile", default="default")
2037
+ datasource_verify.add_argument("--bind", action="store_true")
2038
+ datasource_verify.set_defaults(handler=datasource_verify_command)
2039
+
2040
+ schema_discover = sub.add_parser("schema-discover")
2041
+ schema_discover.add_argument("--install-root")
2042
+ schema_discover.add_argument("--data-root")
2043
+ schema_discover.add_argument("--profile", default="default")
2044
+ schema_discover.add_argument("--schema", action="append")
2045
+ schema_discover.add_argument("--pattern")
2046
+ schema_discover.add_argument("--table", action="append")
2047
+ schema_discover.add_argument("--max-tables", type=int, default=25)
2048
+ schema_discover.add_argument("--list-only", action="store_true")
2049
+ schema_discover.add_argument("--no-views", action="store_true")
2050
+ schema_discover.add_argument("--include-indexes", action="store_true")
2051
+ schema_discover.set_defaults(handler=schema_discover_command)
2052
+
2053
+ semantic_draft = sub.add_parser("semantic-draft")
2054
+ semantic_draft.add_argument("--install-root")
2055
+ semantic_draft.add_argument("--data-root")
2056
+ semantic_draft.add_argument("--profile", default="default")
2057
+ semantic_draft.add_argument("--semantic-plan", required=True)
2058
+ semantic_draft.set_defaults(handler=semantic_draft_command)
2059
+
2060
+ semantic_validate = sub.add_parser("semantic-validate")
2061
+ semantic_validate.add_argument("--install-root")
2062
+ semantic_validate.add_argument("--data-root")
2063
+ semantic_validate.add_argument("--profile", default="default")
2064
+ semantic_validate.add_argument("--apply", action="store_true")
2065
+ semantic_validate.add_argument("--include-stack-trace", action="store_true")
2066
+ semantic_validate.set_defaults(handler=semantic_validate_command)
2067
+
2068
+ semantic_publish = sub.add_parser("semantic-publish")
2069
+ semantic_publish.add_argument("--install-root")
2070
+ semantic_publish.add_argument("--data-root")
2071
+ semantic_publish.add_argument("--profile", default="default")
2072
+ semantic_publish.add_argument("--apply", action="store_true")
2073
+ semantic_publish.add_argument("--replace-bundle", action="store_true")
2074
+ semantic_publish.add_argument("--watch", action="store_true")
2075
+ semantic_publish.add_argument("--prune", action="store_true")
2076
+ semantic_publish.set_defaults(handler=semantic_publish_command)
2077
+
2078
+ semantic_verify = sub.add_parser("semantic-verify")
2079
+ semantic_verify.add_argument("--install-root")
2080
+ semantic_verify.add_argument("--data-root")
2081
+ semantic_verify.add_argument("--profile", default="default")
2082
+ semantic_verify.add_argument("--query-model")
2083
+ semantic_verify.add_argument("--query-payload", required=True)
2084
+ semantic_verify.add_argument("--execute", action="store_true")
2085
+ semantic_verify.set_defaults(handler=semantic_verify_command)
2086
+
2087
+ uninstall = sub.add_parser("uninstall")
2088
+ uninstall.add_argument("--install-root")
2089
+ uninstall.add_argument("--yes", action="store_true")
2090
+ uninstall.add_argument("--dry-run", action="store_true")
2091
+ uninstall.add_argument("--remove-skills", action="store_true")
2092
+ uninstall.add_argument("--purge-data", action="store_true")
2093
+ uninstall.add_argument("--force", action="store_true")
2094
+ uninstall.set_defaults(handler=uninstall_command)
2095
+ return parser
2096
+
2097
+
2098
+ def main() -> None:
2099
+ args = build_parser().parse_args()
2100
+ try:
2101
+ result = args.handler(args)
2102
+ exit_code = 0
2103
+ if args.command == "doctor" and not result["success"] and not args.no_fail:
2104
+ exit_code = 1
2105
+ emit(result, exit_code)
2106
+ except OnboardingError as exc:
2107
+ emit({"success": False, "error": {"code": "ONBOARDING_ERROR", "message": str(exc)}, "productionReady": False}, 1)
2108
+ except Exception as exc:
2109
+ emit({"success": False, "error": {"code": "UNEXPECTED_ERROR", "message": str(exc)}, "productionReady": False}, 1)
2110
+
2111
+
2112
+ if __name__ == "__main__":
2113
+ main()