@foggy-projects/deepseek-harness-plugin 0.4.0-beta.9 → 0.4.0-rc.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.
@@ -18,6 +18,7 @@ import subprocess
18
18
  import sys
19
19
  import tempfile
20
20
  import time
21
+ import urllib.error
21
22
  import urllib.request
22
23
  import venv
23
24
  import zipfile
@@ -25,6 +26,8 @@ import zipfile
25
26
 
26
27
  STATE_SCHEMA = "foggy-deepseek-onboarding-install/v1"
27
28
  RUNTIME_STATE_SCHEMA = "foggy-deepseek-onboarding-runtime/v1"
29
+ RUNTIME_SETTINGS_SCHEMA = "foggy-deepseek-runtime-settings/v1"
30
+ RUNTIME_PORT_CONFLICT_SCHEMA = "foggy-deepseek-runtime-port-conflict/v1"
28
31
  ONBOARDING_STATE_SCHEMA = "foggy-deepseek-onboarding-state/v1"
29
32
  MANAGED_SKILL_SCHEMA = "foggy-managed-skill/v1"
30
33
  MANAGED_SKILL_MARKER = ".foggy-managed-skill.json"
@@ -43,25 +46,48 @@ class OnboardingError(RuntimeError):
43
46
  pass
44
47
 
45
48
 
49
+ class RuntimeSettingsError(OnboardingError):
50
+ code = "RUNTIME_SETTINGS_INVALID"
51
+
52
+
53
+ class RuntimePortUnavailableError(OnboardingError):
54
+ code = "RUNTIME_PORT_UNAVAILABLE"
55
+
56
+
46
57
  PROGRESS_SCHEMA = "foggy-deepseek-onboarding-progress/v1"
47
58
  ACTIVE_PROGRESS: "ProgressReporter | None" = None
48
59
 
49
60
 
50
61
  class ProgressReporter:
51
- total_steps = 7
52
-
53
- def __init__(self, path: str | None, operation_id: str | None, kind: str) -> None:
62
+ def __init__(
63
+ self,
64
+ path: str | None,
65
+ operation_id: str | None,
66
+ kind: str,
67
+ *,
68
+ total_steps: int = 7,
69
+ completion_phase: str = "complete",
70
+ completion_message: str = "Foggy initialization completed",
71
+ failure_message: str = "Foggy initialization failed",
72
+ ) -> None:
54
73
  self.path = Path(path).expanduser() if path else None
55
74
  self.operation_id = operation_id
56
75
  self.kind = kind
76
+ self.total_steps = total_steps
77
+ self.completion_phase = completion_phase
78
+ self.completion_message = completion_message
79
+ self.failure_message = failure_message
57
80
  self.started_at = now_utc()
58
81
  self.phase = "preflight"
59
82
  self.step_index = 0
60
83
  self.fraction = 0.0
84
+ self.percent_override: int | None = None
61
85
  self.message = ""
62
86
  self.current_file: str | None = None
63
87
  self.completed_files: int | None = None
64
88
  self.total_files: int | None = None
89
+ self.elapsed_seconds: int | None = None
90
+ self.timeout_seconds: int | None = None
65
91
 
66
92
  @property
67
93
  def enabled(self) -> bool:
@@ -72,7 +98,11 @@ class ProgressReporter:
72
98
  return
73
99
  bounded_step = max(0, min(self.step_index, self.total_steps))
74
100
  bounded_fraction = max(0.0, min(float(self.fraction), 1.0))
75
- percent = 100 if state == "succeeded" else round(((bounded_step + bounded_fraction) / self.total_steps) * 100)
101
+ percent = 100 if state == "succeeded" else (
102
+ max(0, min(self.percent_override, 99))
103
+ if self.percent_override is not None
104
+ else round(((bounded_step + bounded_fraction) / self.total_steps) * 100)
105
+ )
76
106
  payload = {
77
107
  "schemaVersion": PROGRESS_SCHEMA,
78
108
  "operationId": self.operation_id,
@@ -88,6 +118,10 @@ class ProgressReporter:
88
118
  }
89
119
  if self.completed_files is not None and self.total_files is not None:
90
120
  payload["files"] = {"completed": self.completed_files, "total": self.total_files}
121
+ if self.elapsed_seconds is not None:
122
+ payload["timing"] = {"elapsedSeconds": self.elapsed_seconds}
123
+ if self.timeout_seconds is not None:
124
+ payload["timing"]["timeoutSeconds"] = self.timeout_seconds
91
125
  if finished_at:
92
126
  payload["finishedAt"] = finished_at
93
127
  if error:
@@ -105,28 +139,37 @@ class ProgressReporter:
105
139
  current_file: str | None = None,
106
140
  completed_files: int | None = None,
107
141
  total_files: int | None = None,
142
+ percent: int | None = None,
143
+ elapsed_seconds: int | None = None,
144
+ timeout_seconds: int | None = None,
108
145
  ) -> None:
109
146
  self.phase = phase
110
147
  self.step_index = step_index
111
148
  self.fraction = fraction
149
+ self.percent_override = percent
112
150
  self.message = message
113
151
  self.current_file = current_file
114
152
  self.completed_files = completed_files
115
153
  self.total_files = total_files
154
+ self.elapsed_seconds = elapsed_seconds
155
+ self.timeout_seconds = timeout_seconds
116
156
  self._write("running")
117
157
 
118
158
  def finish(self) -> None:
119
- self.phase = "complete"
159
+ self.phase = self.completion_phase
120
160
  self.step_index = self.total_steps
121
161
  self.fraction = 0.0
122
- self.message = "Foggy initialization completed"
162
+ self.percent_override = 100
163
+ self.message = self.completion_message
123
164
  self.current_file = None
124
165
  self.completed_files = None
125
166
  self.total_files = None
167
+ self.elapsed_seconds = None
168
+ self.timeout_seconds = None
126
169
  self._write("succeeded", finished_at=now_utc())
127
170
 
128
171
  def fail(self, error: object) -> None:
129
- self.message = "Foggy initialization failed"
172
+ self.message = self.failure_message
130
173
  self._write("failed", finished_at=now_utc(), error=str(error))
131
174
 
132
175
 
@@ -174,6 +217,61 @@ def default_data_root() -> Path:
174
217
  return Path(base) / "foggy" / "deepseek-harness" if base else Path.home() / ".local" / "state" / "foggy" / "deepseek-harness"
175
218
 
176
219
 
220
+ def validate_runtime_port(value: object) -> int:
221
+ if isinstance(value, bool):
222
+ raise RuntimeSettingsError("Runtime port must be an integer between 1024 and 65535")
223
+ try:
224
+ port = int(value)
225
+ except (TypeError, ValueError) as exc:
226
+ raise RuntimeSettingsError("Runtime port must be an integer between 1024 and 65535") from exc
227
+ if str(value).strip() != str(port) or port < 1024 or port > 65535:
228
+ raise RuntimeSettingsError("Runtime port must be an integer between 1024 and 65535")
229
+ return port
230
+
231
+
232
+ def read_runtime_settings(data_root: Path, default_port: object) -> dict:
233
+ path = data_root / "runtime-settings.json"
234
+ if not path.is_file():
235
+ port = validate_runtime_port(default_port)
236
+ return {"schemaVersion": RUNTIME_SETTINGS_SCHEMA, "port": port, "source": "default", "path": str(path)}
237
+ try:
238
+ payload = json.loads(path.read_text(encoding="utf-8"))
239
+ except (OSError, json.JSONDecodeError) as exc:
240
+ raise RuntimeSettingsError(f"Runtime settings file is invalid: {path}") from exc
241
+ if payload.get("schemaVersion") != RUNTIME_SETTINGS_SCHEMA:
242
+ raise RuntimeSettingsError(f"Unexpected Runtime settings schema in {path}")
243
+ port = validate_runtime_port(payload.get("runtimePort"))
244
+ return {"schemaVersion": RUNTIME_SETTINGS_SCHEMA, "port": port, "source": "configured", "path": str(path)}
245
+
246
+
247
+ def assert_runtime_port_available(data_root: Path, port: int) -> None:
248
+ conflict_path = data_root / "last-runtime-port-conflict.json"
249
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
250
+ if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
251
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
252
+ try:
253
+ # The packaged Java server uses a wildcard listener. Checking only
254
+ # 127.0.0.1 misses Windows portproxy entries bound to another local address.
255
+ probe.bind(("0.0.0.0", port))
256
+ except OSError as exc:
257
+ message = (
258
+ f"Runtime port {port} is unavailable. Another application or Windows port proxy "
259
+ "is already listening. Choose a different port in Foggy plugin settings or release "
260
+ "the port before retrying."
261
+ )
262
+ conflict = {
263
+ "schemaVersion": RUNTIME_PORT_CONFLICT_SCHEMA,
264
+ "detectedAt": now_utc(),
265
+ "port": port,
266
+ "bindAddress": "0.0.0.0",
267
+ "runtimeUrl": f"http://127.0.0.1:{port}",
268
+ "message": message,
269
+ }
270
+ atomic_json(conflict_path, conflict)
271
+ raise RuntimePortUnavailableError(message) from exc
272
+ conflict_path.unlink(missing_ok=True)
273
+
274
+
177
275
  def normalized(path: str | Path) -> Path:
178
276
  return Path(path).expanduser().resolve(strict=False)
179
277
 
@@ -245,6 +343,128 @@ def command_result(command: list[str], timeout: int = 30, check: bool = False) -
245
343
  return payload
246
344
 
247
345
 
346
+ def process_running(pid: int) -> bool:
347
+ """Check liveness without spawning a shell or waiting on the target process."""
348
+ if pid <= 0:
349
+ return False
350
+ if os.name == "nt":
351
+ import ctypes
352
+
353
+ synchronize = 0x00100000
354
+ wait_timeout = 0x00000102
355
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
356
+ kernel32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
357
+ kernel32.OpenProcess.restype = ctypes.c_void_p
358
+ kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
359
+ kernel32.WaitForSingleObject.restype = ctypes.c_uint32
360
+ kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
361
+ handle = kernel32.OpenProcess(synchronize, False, pid)
362
+ if not handle:
363
+ return ctypes.get_last_error() == 5 # Access denied still proves the PID exists.
364
+ try:
365
+ return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
366
+ finally:
367
+ kernel32.CloseHandle(handle)
368
+ proc_stat = Path("/proc") / str(pid) / "stat"
369
+ if proc_stat.is_file():
370
+ try:
371
+ fields = proc_stat.read_text(encoding="utf-8", errors="replace").rsplit(") ", 1)[1]
372
+ return bool(fields) and fields[0] != "Z"
373
+ except (OSError, IndexError):
374
+ pass
375
+ try:
376
+ os.kill(pid, 0)
377
+ except ProcessLookupError:
378
+ return False
379
+ except PermissionError:
380
+ return True
381
+ except OSError:
382
+ return False
383
+ return True
384
+
385
+
386
+ def command_result_with_progress(
387
+ command: list[str],
388
+ timeout: int,
389
+ progress: ProgressReporter,
390
+ *,
391
+ phase: str,
392
+ step_index: int,
393
+ message: str,
394
+ readiness_timeout: int,
395
+ start_percent: int,
396
+ end_percent: int,
397
+ poll_interval: float = 1.0,
398
+ watched_pid: int | None = None,
399
+ watched_label: str = "Runtime",
400
+ ) -> dict:
401
+ started = time.monotonic()
402
+ try:
403
+ process = subprocess.Popen(
404
+ command,
405
+ stdout=subprocess.PIPE,
406
+ stderr=subprocess.PIPE,
407
+ text=True,
408
+ encoding="utf-8",
409
+ errors="replace",
410
+ )
411
+ except FileNotFoundError:
412
+ return {
413
+ "command": command[0],
414
+ "available": False,
415
+ "exitCode": None,
416
+ "stdout": "",
417
+ "stderr": "command not found",
418
+ "durationMs": round((time.monotonic() - started) * 1000),
419
+ }
420
+
421
+ stdout = ""
422
+ stderr = ""
423
+ while True:
424
+ try:
425
+ stdout, stderr = process.communicate(timeout=poll_interval)
426
+ break
427
+ except subprocess.TimeoutExpired:
428
+ elapsed = time.monotonic() - started
429
+ if watched_pid and not process_running(watched_pid):
430
+ process.kill()
431
+ process.communicate()
432
+ raise OnboardingError(
433
+ f"{watched_label} process PID {watched_pid} exited before becoming ready "
434
+ f"after {round(elapsed * 1000)} ms"
435
+ )
436
+ readiness_fraction = min(elapsed / max(readiness_timeout, 1), 1.0)
437
+ percent = round(start_percent + (end_percent - start_percent) * readiness_fraction)
438
+ progress.update(
439
+ phase,
440
+ step_index,
441
+ message,
442
+ percent=percent,
443
+ elapsed_seconds=max(1, round(elapsed)),
444
+ timeout_seconds=readiness_timeout,
445
+ )
446
+ if elapsed >= timeout:
447
+ process.kill()
448
+ stdout, stderr = process.communicate()
449
+ return {
450
+ "command": command[0],
451
+ "available": True,
452
+ "exitCode": None,
453
+ "stdout": stdout.strip(),
454
+ "stderr": f"timed out after {timeout} seconds",
455
+ "durationMs": round((time.monotonic() - started) * 1000),
456
+ }
457
+
458
+ return {
459
+ "command": command[0],
460
+ "available": True,
461
+ "exitCode": process.returncode,
462
+ "stdout": stdout.strip(),
463
+ "stderr": stderr.strip(),
464
+ "durationMs": round((time.monotonic() - started) * 1000),
465
+ }
466
+
467
+
248
468
  def sha256(path: Path) -> str:
249
469
  digest = hashlib.sha256()
250
470
  with path.open("rb") as stream:
@@ -699,7 +919,7 @@ def validate_connection(payload: dict) -> dict:
699
919
  if payload.get("schemaVersion") != CONNECTION_SCHEMA:
700
920
  raise OnboardingError(f"connection schemaVersion must be {CONNECTION_SCHEMA}")
701
921
  allowed = {
702
- "schemaVersion", "name", "type", "jdbcUrl", "username", "passwordEnv",
922
+ "schemaVersion", "name", "type", "jdbcUrl", "username", "password", "passwordEnv",
703
923
  "opaqueProfileId", "opaqueRevision",
704
924
  "profile", "namespace", "schemas", "modelsDir", "evidenceDir", "readOnlyRecommended",
705
925
  }
@@ -711,6 +931,7 @@ def validate_connection(payload: dict) -> dict:
711
931
  for name in required:
712
932
  if not isinstance(payload.get(name), str) or not payload[name].strip():
713
933
  raise OnboardingError(f"connection.{name} must be a non-empty string")
934
+ password = payload.get("password")
714
935
  password_env = payload.get("passwordEnv")
715
936
  jdbc_url = None
716
937
  if opaque:
@@ -718,15 +939,19 @@ def validate_connection(payload: dict) -> dict:
718
939
  raise OnboardingError("connection.opaqueProfileId must be an opaque Foggy profile ID")
719
940
  if not isinstance(payload.get("opaqueRevision"), str) or not OPAQUE_REVISION_PATTERN.fullmatch(payload["opaqueRevision"]):
720
941
  raise OnboardingError("connection.opaqueRevision must be a sha256 revision")
721
- exposed = sorted(name for name in ("jdbcUrl", "username", "passwordEnv") if name in payload)
942
+ exposed = sorted(name for name in ("jdbcUrl", "username", "password", "passwordEnv") if name in payload)
722
943
  if exposed:
723
944
  raise OnboardingError(f"Opaque connection plans must not contain: {', '.join(exposed)}")
724
945
  else:
946
+ if password is not None and not isinstance(password, str):
947
+ raise OnboardingError("connection.password must be a string")
725
948
  if password_env is not None and (not isinstance(password_env, str) or not ENV_NAME_PATTERN.fullmatch(password_env)):
726
949
  raise OnboardingError("connection.passwordEnv must be an environment variable name")
950
+ if password is not None and password_env is not None:
951
+ raise OnboardingError("Provide either connection.password or connection.passwordEnv, not both")
727
952
  jdbc_url = payload["jdbcUrl"].strip()
728
953
  if re.search(r"(?i)(?:password|passwd|pwd)\s*=", jdbc_url) or re.search(r"//[^/@:]+:[^/@]+@", jdbc_url):
729
- raise OnboardingError("Do not embed passwords in jdbcUrl; use passwordEnv")
954
+ raise OnboardingError("Do not embed passwords in jdbcUrl; use password or passwordEnv")
730
955
  schemas = payload.get("schemas", [])
731
956
  if not isinstance(schemas, list) or any(not isinstance(item, str) or not item.strip() for item in schemas):
732
957
  raise OnboardingError("connection.schemas must be an array of non-empty strings")
@@ -754,6 +979,7 @@ def validate_connection(payload: dict) -> dict:
754
979
  else:
755
980
  result["jdbcUrl"] = jdbc_url
756
981
  result["username"] = payload.get("username")
982
+ result["password"] = password
757
983
  result["passwordEnv"] = password_env
758
984
  if payload.get("profile") is not None:
759
985
  result["profile"] = safe_profile(payload["profile"])
@@ -761,11 +987,24 @@ def validate_connection(payload: dict) -> dict:
761
987
  result["evidenceDir"] = payload["evidenceDir"].strip()
762
988
  if result["type"] not in {"sqlite", "mysql", "postgres", "postgresql"}:
763
989
  raise OnboardingError("Initial onboarding supports sqlite, mysql, postgres, and postgresql")
764
- if not opaque and result["type"] != "sqlite" and not result["passwordEnv"]:
765
- raise OnboardingError("Non-SQLite connections require passwordEnv")
766
990
  return result
767
991
 
768
992
 
993
+ def persisted_connection(connection: dict) -> dict:
994
+ """Return the resumable connection contract without an inline development password."""
995
+ persisted = {key: value for key, value in connection.items() if key != "password"}
996
+ credential_mode = connection.get("credentialMode")
997
+ if credential_mode not in {"inline-development", "agent-environment", "opaque-profile", "none"}:
998
+ credential_mode = (
999
+ "inline-development" if connection.get("password") is not None
1000
+ else "agent-environment" if connection.get("passwordEnv")
1001
+ else "opaque-profile" if connection.get("connectionMode") == "opaque-profile"
1002
+ else "none"
1003
+ )
1004
+ persisted["credentialMode"] = credential_mode
1005
+ return persisted
1006
+
1007
+
769
1008
  def validate_semantic_plan(payload: dict) -> dict:
770
1009
  if payload.get("schemaVersion") != SEMANTIC_PLAN_SCHEMA:
771
1010
  raise OnboardingError(f"semantic plan schemaVersion must be {SEMANTIC_PLAN_SCHEMA}")
@@ -929,22 +1168,26 @@ def safe_extract(zip_path: Path, destination: Path) -> None:
929
1168
  archive.extractall(destination)
930
1169
 
931
1170
 
932
- def install_analysis_skill(
1171
+ def install_managed_skill(
933
1172
  zip_path: Path,
934
1173
  install_root: Path,
935
1174
  version: str,
936
1175
  expected_hash: str,
937
1176
  package_version: str,
938
1177
  replace: bool,
1178
+ *,
1179
+ skill_name: str,
1180
+ kind: str,
1181
+ display_name: str,
939
1182
  ) -> dict:
940
1183
  skills_root = install_root / "skills"
941
- destination = skills_root / "foggy-ai-analysis"
1184
+ destination = skills_root / skill_name
942
1185
  with tempfile.TemporaryDirectory(prefix="foggy-skill-") as temporary:
943
1186
  extract_root = Path(temporary)
944
1187
  safe_extract(zip_path, extract_root)
945
1188
  candidates = list(extract_root.rglob("SKILL.md"))
946
1189
  if len(candidates) != 1:
947
- raise OnboardingError(f"Expected exactly one SKILL.md in analysis Skill archive, found {len(candidates)}")
1190
+ raise OnboardingError(f"Expected exactly one SKILL.md in {display_name} archive, found {len(candidates)}")
948
1191
  source = candidates[0].parent
949
1192
  source_digest = skill_tree_digest(source)
950
1193
  if destination.exists():
@@ -960,7 +1203,7 @@ def install_analysis_skill(
960
1203
  if marker_matches:
961
1204
  marker = write_skill_marker(
962
1205
  destination,
963
- kind="analysis",
1206
+ kind=kind,
964
1207
  package_version=package_version,
965
1208
  component_version=version,
966
1209
  source_digest=source_digest,
@@ -968,13 +1211,13 @@ def install_analysis_skill(
968
1211
  )
969
1212
  return {"path": str(destination), "version": version, "digest": marker["installedDigest"], "managed": True, "action": "kept-matching"}
970
1213
  if not replace:
971
- raise OnboardingError(f"Analysis Skill is missing, modified, or outdated at {destination}; use the plugin Repair action to back it up and restore it")
1214
+ raise OnboardingError(f"{display_name} is missing, modified, or outdated at {destination}; use the plugin Repair action to back it up and restore it")
972
1215
  backup_skill(destination, install_root / "skill-backups")
973
1216
  skills_root.mkdir(parents=True, exist_ok=True)
974
1217
  shutil.copytree(source, destination)
975
1218
  marker = write_skill_marker(
976
1219
  destination,
977
- kind="analysis",
1220
+ kind=kind,
978
1221
  package_version=package_version,
979
1222
  component_version=version,
980
1223
  source_digest=source_digest,
@@ -1025,7 +1268,7 @@ def install_command(args: argparse.Namespace) -> dict:
1025
1268
  "workspaceMode": "dsh-session-cwd",
1026
1269
  "versions": {name: value.get("version") for name, value in components.items()},
1027
1270
  "repairComponent": repair_component,
1028
- "operations": ["verify private Python", "install isolated CLI", "verify Launcher assets", "install global analysis Skill", "write install state"],
1271
+ "operations": ["verify private Python", "install isolated CLI", "verify Launcher assets", "install global analysis/query Skills", "write install state"],
1029
1272
  "productionReady": False,
1030
1273
  }
1031
1274
  if args.dry_run:
@@ -1125,32 +1368,41 @@ def install_command(args: argparse.Namespace) -> dict:
1125
1368
  (launcher_dir / "start-foggy-runtime.sh").chmod(0o755)
1126
1369
  progress.update("launcher", 3, "Launcher ready", fraction=1.0)
1127
1370
 
1128
- analysis_assets = components["analysisSkill"]["assets"]
1129
- for index, asset in enumerate(analysis_assets):
1130
- progress.update(
1131
- "analysis-skill", 4, "Downloading and verifying analysis Skill",
1132
- fraction=index / len(analysis_assets), current_file=asset["file"],
1133
- completed_files=index, total_files=len(analysis_assets),
1134
- )
1135
- verified.append(materialize(
1136
- asset, downloads / "skill" / asset["file"], cache_dirs,
1137
- progress=progress, progress_phase="analysis-skill", progress_step=4,
1138
- progress_index=index, progress_total=len(analysis_assets),
1139
- progress_message="Downloading and verifying analysis Skill",
1140
- replace_corrupt=repair_component == "analysis-skill",
1141
- ))
1142
- progress.update(
1143
- "analysis-skill", 4, "Downloading and verifying analysis Skill",
1144
- fraction=(index + 1) / len(analysis_assets), current_file=asset["file"],
1145
- completed_files=index + 1, total_files=len(analysis_assets),
1146
- )
1147
- zip_asset = next(item for item in analysis_assets if item["role"] == "zip")
1148
- progress.update("analysis-skill", 4, "Installing analysis Skill", fraction=0.9, current_file=zip_asset["file"])
1149
- analysis_skill = install_analysis_skill(
1150
- downloads / "skill" / zip_asset["file"], install_root, components["analysisSkill"]["version"],
1151
- zip_asset["sha256"], versions["packageVersion"], args.replace_skill or repair_component == "analysis-skill",
1371
+ installed_skills = {}
1372
+ skill_specs = (
1373
+ ("analysisSkill", "analysis", "foggy-ai-analysis", "Analysis Skill", "analysis-skill"),
1374
+ ("semanticQuerySkill", "semantic-query", "foggy-semantic-query", "Semantic Query Skill", "semantic-query-skill"),
1152
1375
  )
1153
- progress.update("analysis-skill", 4, "Analysis Skill ready", fraction=1.0)
1376
+ for component_key, state_key, skill_name, display_name, repair_key in skill_specs:
1377
+ skill_assets = components[component_key]["assets"]
1378
+ for index, asset in enumerate(skill_assets):
1379
+ progress.update(
1380
+ repair_key, 4, f"Downloading and verifying {display_name}",
1381
+ fraction=index / len(skill_assets), current_file=asset["file"],
1382
+ completed_files=index, total_files=len(skill_assets),
1383
+ )
1384
+ verified.append(materialize(
1385
+ asset, downloads / "skill" / asset["file"], cache_dirs,
1386
+ progress=progress, progress_phase=repair_key, progress_step=4,
1387
+ progress_index=index, progress_total=len(skill_assets),
1388
+ progress_message=f"Downloading and verifying {display_name}",
1389
+ replace_corrupt=repair_component == repair_key,
1390
+ ))
1391
+ progress.update(
1392
+ repair_key, 4, f"Downloading and verifying {display_name}",
1393
+ fraction=(index + 1) / len(skill_assets), current_file=asset["file"],
1394
+ completed_files=index + 1, total_files=len(skill_assets),
1395
+ )
1396
+ zip_asset = next(item for item in skill_assets if item["role"] == "zip")
1397
+ progress.update(repair_key, 4, f"Installing {display_name}", fraction=0.9, current_file=zip_asset["file"])
1398
+ installed_skills[state_key] = install_managed_skill(
1399
+ downloads / "skill" / zip_asset["file"], install_root, components[component_key]["version"],
1400
+ zip_asset["sha256"], versions["packageVersion"], args.replace_skill or repair_component == repair_key,
1401
+ skill_name=skill_name, kind=state_key, display_name=display_name,
1402
+ )
1403
+ progress.update(repair_key, 4, f"{display_name} ready", fraction=1.0)
1404
+ analysis_skill = installed_skills["analysis"]
1405
+ semantic_query_skill = installed_skills["semantic-query"]
1154
1406
  progress.update("workspace-skills", 5, "Registering native DSH Skills", fraction=0.1)
1155
1407
  onboarding_skill = {
1156
1408
  "path": str(skill_root()),
@@ -1175,7 +1427,7 @@ def install_command(args: argparse.Namespace) -> dict:
1175
1427
  },
1176
1428
  "cli": {"version": cli_component["version"], "command": str(cli_command), "mode": cli_mode},
1177
1429
  "launcher": {"version": components["launcher"]["version"], "path": str(launcher_dir)},
1178
- "skills": {"onboarding": onboarding_skill, "analysis": analysis_skill},
1430
+ "skills": {"onboarding": onboarding_skill, "analysis": analysis_skill, "semanticQuery": semantic_query_skill},
1179
1431
  "verifiedAssets": verified,
1180
1432
  "securityMode": versions["defaults"]["securityMode"],
1181
1433
  "productionReady": False,
@@ -1194,6 +1446,7 @@ def install_command(args: argparse.Namespace) -> dict:
1194
1446
  "cliVersion": cli_component["version"],
1195
1447
  "launcherVersion": components["launcher"]["version"],
1196
1448
  "analysisSkill": analysis_skill,
1449
+ "semanticQuerySkill": semantic_query_skill,
1197
1450
  "onboardingSkill": onboarding_skill,
1198
1451
  "java": java_probe(),
1199
1452
  "next": "run doctor, then runtime-start",
@@ -1276,8 +1529,57 @@ def parse_json_output(result: dict, label: str) -> dict:
1276
1529
  raise OnboardingError(f"{label} did not return JSON") from exc
1277
1530
 
1278
1531
 
1532
+ def write_runtime_start_failure(
1533
+ data_root: Path,
1534
+ evidence_dir: Path,
1535
+ launch: dict,
1536
+ phase: str,
1537
+ error: object,
1538
+ elapsed_ms: int,
1539
+ ) -> dict:
1540
+ logs = []
1541
+ for name in ("stdoutLog", "stderrLog"):
1542
+ value = launch.get(name)
1543
+ path = normalized(value) if isinstance(value, str) and value else None
1544
+ item = {"name": name, "path": str(path) if path else None, "exists": bool(path and path.is_file())}
1545
+ if path and path.is_file():
1546
+ stat = path.stat()
1547
+ item.update({"sizeBytes": stat.st_size, "modifiedAt": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat()})
1548
+ logs.append(item)
1549
+ pid = int(launch.get("pid", 0))
1550
+ failure = {
1551
+ "schemaVersion": "foggy-deepseek-runtime-start-failure/v1",
1552
+ "failedAt": now_utc(),
1553
+ "phase": phase,
1554
+ "message": str(error),
1555
+ "elapsedMs": elapsed_ms,
1556
+ "pid": pid or None,
1557
+ "processRunningBeforeCleanup": process_info(pid)["running"] if pid else False,
1558
+ "runtimeUrl": launch.get("runtimeUrl"),
1559
+ "workDir": launch.get("workDir"),
1560
+ "evidenceDir": str(evidence_dir),
1561
+ "logs": logs,
1562
+ }
1563
+ atomic_json(evidence_dir / "failure.json", failure)
1564
+ atomic_json(data_root / "last-runtime-start-failure.json", failure)
1565
+ return failure
1566
+
1567
+
1279
1568
  def runtime_start_command(args: argparse.Namespace) -> dict:
1569
+ global ACTIVE_PROGRESS
1280
1570
  versions = load_versions()
1571
+ readiness_timeout = args.timeout or int(versions["defaults"]["readinessTimeoutSeconds"])
1572
+ progress = ProgressReporter(
1573
+ getattr(args, "progress_file", None),
1574
+ getattr(args, "operation_id", None),
1575
+ getattr(args, "operation_kind", "runtime-start"),
1576
+ total_steps=6,
1577
+ completion_phase="runtime-complete",
1578
+ completion_message="Foggy Runtime is ready",
1579
+ failure_message="Foggy Runtime startup failed",
1580
+ )
1581
+ ACTIVE_PROGRESS = progress
1582
+ progress.update("runtime-preflight", 0, "Checking Runtime state and prerequisites", percent=3)
1281
1583
  install_root = normalized(args.install_root or default_install_root())
1282
1584
  state = read_install_state(install_root)
1283
1585
  data_root = normalized(args.data_root or state["dataRoot"])
@@ -1296,15 +1598,25 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1296
1598
  base_url = prior.get("runtimeUrl")
1297
1599
  if not isinstance(base_url, str) or not base_url:
1298
1600
  raise OnboardingError("Recorded Runtime does not contain runtimeUrl")
1299
- wait_result = command_result(
1601
+ progress.update("runtime-readiness", 3, "Revalidating the recorded Runtime", percent=30, elapsed_seconds=0, timeout_seconds=readiness_timeout)
1602
+ wait_result = command_result_with_progress(
1300
1603
  [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready",
1301
- "--timeout-seconds", str(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]),
1604
+ "--timeout-seconds", str(readiness_timeout),
1302
1605
  "--interval-seconds", "1"],
1303
- timeout=(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]) + 30,
1606
+ readiness_timeout + 30,
1607
+ progress,
1608
+ phase="runtime-readiness",
1609
+ step_index=3,
1610
+ message="Revalidating the recorded Runtime",
1611
+ readiness_timeout=readiness_timeout,
1612
+ start_percent=30,
1613
+ end_percent=85,
1614
+ watched_pid=int(prior["pid"]),
1304
1615
  )
1305
1616
  wait_payload = parse_json_output(wait_result, "wait-ready")
1306
1617
  if wait_payload.get("success") is not True:
1307
1618
  raise OnboardingError("wait-ready returned success=false for recorded Runtime")
1619
+ progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
1308
1620
  capabilities = parse_json_output(
1309
1621
  command_result(
1310
1622
  [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"],
@@ -1324,6 +1636,8 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1324
1636
  "schemaVersion": capabilities.get("data", {}).get("schemaVersion"),
1325
1637
  "securityMode": capabilities.get("data", {}).get("securityMode"),
1326
1638
  }
1639
+ progress.finish()
1640
+ ACTIVE_PROGRESS = None
1327
1641
  return {
1328
1642
  "success": True,
1329
1643
  **prior,
@@ -1340,16 +1654,15 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1340
1654
  "productionReady": False,
1341
1655
  }
1342
1656
  existing.unlink()
1343
- port = args.port or int(versions["defaults"]["port"])
1344
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
1345
- try:
1346
- probe.bind(("127.0.0.1", port))
1347
- except OSError as exc:
1348
- raise OnboardingError(f"Port {port} is not available") from exc
1657
+ progress.update("runtime-preflight", 1, "Checking port and Runtime workspace", percent=10)
1658
+ configured_runtime = read_runtime_settings(data_root, versions["defaults"]["port"])
1659
+ port = validate_runtime_port(args.port) if args.port is not None else configured_runtime["port"]
1660
+ assert_runtime_port_available(data_root, port)
1349
1661
  work_dir = data_root / "runtime"
1350
1662
  work_dir.mkdir(parents=True, exist_ok=True)
1351
1663
  launcher_dir = normalized(state["launcher"]["path"])
1352
1664
  java_exe = args.java or os.environ.get("JAVA_EXE", "java")
1665
+ progress.update("runtime-launcher", 2, "Starting the Java Runtime through Launcher", percent=20)
1353
1666
  if os.name == "nt":
1354
1667
  shell = shutil.which("pwsh") or shutil.which("powershell")
1355
1668
  if not shell:
@@ -1397,12 +1710,26 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1397
1710
  evidence_dir = data_root / "evidence" / f"runtime-start-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
1398
1711
  evidence_dir.mkdir(parents=True, exist_ok=True)
1399
1712
  atomic_json(evidence_dir / "launch.json", launch)
1713
+ startup_started = time.monotonic()
1400
1714
  try:
1401
- 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)
1715
+ progress.update("runtime-readiness", 3, "Waiting for Java Runtime readiness", percent=30, elapsed_seconds=0, timeout_seconds=readiness_timeout)
1716
+ wait_result = command_result_with_progress(
1717
+ [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready", "--timeout-seconds", str(readiness_timeout), "--interval-seconds", "1"],
1718
+ readiness_timeout + 30,
1719
+ progress,
1720
+ phase="runtime-readiness",
1721
+ step_index=3,
1722
+ message="Waiting for Java Runtime readiness",
1723
+ readiness_timeout=readiness_timeout,
1724
+ start_percent=30,
1725
+ end_percent=85,
1726
+ watched_pid=pid,
1727
+ )
1402
1728
  wait_payload = parse_json_output(wait_result, "wait-ready")
1403
1729
  if wait_payload.get("success") is not True:
1404
1730
  raise OnboardingError("wait-ready returned success=false")
1405
1731
  atomic_json(evidence_dir / "wait-ready.json", wait_payload)
1732
+ progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
1406
1733
  capabilities_result = command_result([cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"], timeout=30)
1407
1734
  capabilities = parse_json_output(capabilities_result, "capabilities")
1408
1735
  expected_contract = versions["components"]["launcher"]["runtimeApiContract"]
@@ -1411,12 +1738,21 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1411
1738
  if capabilities.get("data", {}).get("securityMode") != versions["defaults"]["securityMode"]:
1412
1739
  raise OnboardingError("Packaged Launcher did not report the expected dev/test security mode")
1413
1740
  atomic_json(evidence_dir / "capabilities.json", capabilities)
1414
- except Exception:
1741
+ except Exception as exc:
1742
+ failure = write_runtime_start_failure(
1743
+ data_root,
1744
+ evidence_dir,
1745
+ launch,
1746
+ progress.phase,
1747
+ exc,
1748
+ round((time.monotonic() - startup_started) * 1000),
1749
+ )
1415
1750
  try:
1416
1751
  os.kill(pid, signal.SIGTERM)
1417
1752
  except OSError:
1418
1753
  pass
1419
- raise
1754
+ raise OnboardingError(f"{exc}; startup diagnostics: {failure['evidenceDir']}") from exc
1755
+ progress.update("runtime-state", 5, "Saving verified Runtime state", percent=96, current_file="runtime-state.json")
1420
1756
  runtime_state = {
1421
1757
  "schemaVersion": RUNTIME_STATE_SCHEMA,
1422
1758
  "startedAt": now_utc(),
@@ -1435,6 +1771,9 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1435
1771
  },
1436
1772
  }
1437
1773
  atomic_json(data_root / "runtime-state.json", runtime_state)
1774
+ (data_root / "last-runtime-start-failure.json").unlink(missing_ok=True)
1775
+ progress.finish()
1776
+ ACTIVE_PROGRESS = None
1438
1777
  return {"success": True, **runtime_state, "productionReady": False}
1439
1778
 
1440
1779
 
@@ -1475,6 +1814,108 @@ def cli_json(install_state: dict, runtime_state: dict, namespace: str, command:
1475
1814
  return payload
1476
1815
 
1477
1816
 
1817
+ TRANSIENT_DATASOURCE_TEST_MARKERS = (
1818
+ "connection is not available",
1819
+ "connection pool",
1820
+ "request timed out",
1821
+ "timeout waiting for connection",
1822
+ )
1823
+
1824
+
1825
+ def datasource_test_with_retry(
1826
+ install_state: dict,
1827
+ runtime_state: dict,
1828
+ namespace: str,
1829
+ datasource_name: str,
1830
+ *,
1831
+ attempts: int = 3,
1832
+ initial_delay_seconds: float = 1.0,
1833
+ ) -> dict:
1834
+ """Retry only transient pool/readiness failures; authentication and configuration failures fail fast."""
1835
+ for attempt in range(1, attempts + 1):
1836
+ try:
1837
+ result = cli_json(
1838
+ install_state,
1839
+ runtime_state,
1840
+ namespace,
1841
+ ["datasources", "test", datasource_name],
1842
+ "datasources test",
1843
+ )
1844
+ if attempt > 1:
1845
+ result = {
1846
+ **result,
1847
+ "onboardingRetry": {
1848
+ "attempts": attempt,
1849
+ "reason": "transient-datasource-readiness",
1850
+ },
1851
+ }
1852
+ return result
1853
+ except OnboardingError as exc:
1854
+ transient = any(marker in str(exc).lower() for marker in TRANSIENT_DATASOURCE_TEST_MARKERS)
1855
+ if not transient or attempt == attempts:
1856
+ raise
1857
+ time.sleep(initial_delay_seconds * attempt)
1858
+ raise AssertionError("unreachable")
1859
+
1860
+
1861
+ def runtime_api_json(
1862
+ runtime_state: dict,
1863
+ namespace: str,
1864
+ method: str,
1865
+ path: str,
1866
+ body: dict | None,
1867
+ label: str,
1868
+ timeout: int = 60,
1869
+ ) -> dict:
1870
+ """Call the public Runtime API for development inputs not yet exposed by the pinned CLI."""
1871
+ base_url = str(runtime_state["runtimeUrl"]).rstrip("/")
1872
+ request_body = json.dumps(body).encode("utf-8") if body is not None else None
1873
+ headers = {
1874
+ "Accept": "application/json",
1875
+ "Content-Type": "application/json",
1876
+ "X-NS": namespace,
1877
+ }
1878
+ auth_code = os.environ.get("FOGGY_RUNTIME_API_AUTH_CODE")
1879
+ if auth_code:
1880
+ headers["X-Foggy-Runtime-Code"] = auth_code
1881
+ request = urllib.request.Request(
1882
+ f"{base_url}{path}",
1883
+ data=request_body,
1884
+ headers=headers,
1885
+ method=method,
1886
+ )
1887
+ try:
1888
+ with urllib.request.urlopen(request, timeout=timeout) as response:
1889
+ raw = response.read().decode("utf-8", errors="replace")
1890
+ except urllib.error.HTTPError as exc:
1891
+ raw = exc.read().decode("utf-8", errors="replace")
1892
+ try:
1893
+ failed = json.loads(raw)
1894
+ except json.JSONDecodeError:
1895
+ failed = None
1896
+ error = failed.get("error") if isinstance(failed, dict) else None
1897
+ detail = (
1898
+ " | ".join(str(error.get(name)) for name in ("code", "phase", "message") if error.get(name))
1899
+ if isinstance(error, dict)
1900
+ else str(exc.reason)
1901
+ )
1902
+ raise OnboardingError(f"{label} failed with HTTP {exc.code}: {detail or exc.reason}") from exc
1903
+ except urllib.error.URLError as exc:
1904
+ raise OnboardingError(f"{label} could not reach Runtime: {exc.reason}") from exc
1905
+ try:
1906
+ payload = json.loads(raw)
1907
+ except json.JSONDecodeError as exc:
1908
+ raise OnboardingError(f"{label} did not return JSON") from exc
1909
+ if payload.get("success") is not True:
1910
+ error = payload.get("error")
1911
+ if isinstance(error, dict):
1912
+ detail = " | ".join(str(error.get(name)) for name in ("code", "phase", "message") if error.get(name))
1913
+ else:
1914
+ detail = str(error or "unknown Runtime error")
1915
+ raise OnboardingError(f"{label} returned success=false: {detail}")
1916
+ return payload
1917
+
1918
+
1478
1919
  CONNECTION_SECRET_KEYS = {
1479
1920
  "jdbcurl", "url", "username", "password", "passwordenv", "passwordref",
1480
1921
  }
@@ -1532,7 +1973,8 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
1532
1973
  if not project_root.is_dir():
1533
1974
  raise OnboardingError(f"Project root not found: {project_root}")
1534
1975
  connection_file = normalized(args.connection_file)
1535
- connection = validate_connection(read_json_object(connection_file, "Connection plan"))
1976
+ requested_connection = validate_connection(read_json_object(connection_file, "Connection plan"))
1977
+ connection = persisted_connection(requested_connection)
1536
1978
  if connection.get("connectionMode") == "opaque-profile":
1537
1979
  require_opaque_profile_cli(install_state)
1538
1980
  existing = read_onboarding_state(data_root, profile, required=False)
@@ -1577,7 +2019,10 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
1577
2019
  "schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
1578
2020
  "profile": profile,
1579
2021
  "statePath": str(path),
1580
- "connection": {**connection, "passwordEnvPresent": bool(password_env and os.environ.get(password_env))},
2022
+ "connection": {
2023
+ **connection,
2024
+ "passwordEnvPresent": bool(password_env and os.environ.get(password_env)),
2025
+ },
1581
2026
  "runtimeAvailable": runtime_state is not None,
1582
2027
  "next": "run datasource-configure --apply after reviewing the plan",
1583
2028
  "productionReady": False,
@@ -1608,10 +2053,11 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
1608
2053
  if opaque:
1609
2054
  plan.update({"profileId": connection["opaqueProfileId"], "revision": connection["opaqueRevision"]})
1610
2055
  else:
2056
+ inline_password = getattr(args, "runtime_password", None)
1611
2057
  plan.update({
1612
2058
  "jdbcUrl": connection["jdbcUrl"],
1613
2059
  "username": connection.get("username"),
1614
- "passwordEnv": connection.get("passwordEnv"),
2060
+ "credentialMode": connection.get("credentialMode", "none"),
1615
2061
  })
1616
2062
  if not args.apply:
1617
2063
  return {"success": True, "dryRun": True, "profile": state["profile"], "plan": plan, "next": "rerun with --apply after approval"}
@@ -1623,22 +2069,50 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
1623
2069
  ]
1624
2070
  label = "opaque profile configure"
1625
2071
  else:
2072
+ inline_password = getattr(args, "runtime_password", None)
1626
2073
  password_env = connection.get("passwordEnv")
1627
- if password_env and not os.environ.get(password_env):
2074
+ if connection.get("credentialMode") == "inline-development" and inline_password is None:
2075
+ raise OnboardingError(
2076
+ "Direct development password is not persisted; rerun onboard-datasource-run with the original connection file"
2077
+ )
2078
+ if inline_password is None and password_env and os.environ.get(password_env) is None:
1628
2079
  raise OnboardingError(f"Required password environment variable is not present: {password_env}")
1629
- command = ["datasources", "add", "--name", connection["name"], "--type", connection["type"], "--jdbc-url", connection["jdbcUrl"]]
1630
- if connection.get("username"):
1631
- command.extend(["--username", connection["username"]])
1632
- if password_env:
1633
- command.extend(["--password-env", password_env])
1634
2080
  label = "datasources add"
1635
- if args.replace:
1636
- command.append("--replace")
2081
+ resolved_password = inline_password if inline_password is not None else (
2082
+ os.environ.get(password_env) if password_env else None
2083
+ )
2084
+ command = None
1637
2085
  already_present = False
1638
2086
  try:
1639
- result = redact_connection_material(
1640
- cli_json(install_state, runtime_state, connection["namespace"], command, label)
1641
- )
2087
+ if not opaque and resolved_password is not None:
2088
+ body = {
2089
+ "name": connection["name"],
2090
+ "type": connection["type"],
2091
+ "jdbcUrl": connection["jdbcUrl"],
2092
+ "replace": bool(args.replace),
2093
+ "enabled": True,
2094
+ "password": resolved_password,
2095
+ }
2096
+ if connection.get("username"):
2097
+ body["username"] = connection["username"]
2098
+ result = redact_connection_material(runtime_api_json(
2099
+ runtime_state,
2100
+ connection["namespace"],
2101
+ "POST",
2102
+ "/api/v1/datasources",
2103
+ body,
2104
+ label,
2105
+ ))
2106
+ else:
2107
+ if not opaque:
2108
+ command = ["datasources", "add", "--name", connection["name"], "--type", connection["type"], "--jdbc-url", connection["jdbcUrl"]]
2109
+ if connection.get("username"):
2110
+ command.extend(["--username", connection["username"]])
2111
+ if args.replace:
2112
+ command.append("--replace")
2113
+ result = redact_connection_material(
2114
+ cli_json(install_state, runtime_state, connection["namespace"], command, label)
2115
+ )
1642
2116
  except OnboardingError as exc:
1643
2117
  if "DATASOURCE_ALREADY_EXISTS" not in str(exc):
1644
2118
  raise
@@ -1679,9 +2153,8 @@ def datasource_verify_command(args: argparse.Namespace) -> dict:
1679
2153
  raise OnboardingError("Datasource is not configured; run datasource-configure --apply first")
1680
2154
  connection = state["connection"]
1681
2155
  namespace = connection["namespace"]
1682
- tested = redact_connection_material(cli_json(
1683
- install_state, runtime_state, namespace,
1684
- ["datasources", "test", connection["name"]], "datasources test",
2156
+ tested = redact_connection_material(datasource_test_with_retry(
2157
+ install_state, runtime_state, namespace, connection["name"],
1685
2158
  ))
1686
2159
  if not args.bind:
1687
2160
  mark_step(state, "datasourceVerified", "waiting-for-binding", connectionTested=True)
@@ -2054,6 +2527,52 @@ def nested_data_objects(payload: dict) -> list[dict]:
2054
2527
  return objects
2055
2528
 
2056
2529
 
2530
+ def runtime_warnings(payload: dict, stage: str) -> list[dict]:
2531
+ """Collect structured query-input and legacy warnings without inventing remediation."""
2532
+ warnings: list[dict] = []
2533
+ seen: set[str] = set()
2534
+ for item in nested_data_objects(payload):
2535
+ for field, kind in (("queryInputWarnings", "query-input"), ("warnings", "legacy")):
2536
+ raw = item.get(field)
2537
+ if raw is None:
2538
+ continue
2539
+ values = raw if isinstance(raw, list) else [raw]
2540
+ for value in values:
2541
+ warning = dict(value) if isinstance(value, dict) else {"message": str(value)}
2542
+ warning["warningKind"] = kind
2543
+ fingerprint = json.dumps(warning, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2544
+ if fingerprint in seen:
2545
+ continue
2546
+ seen.add(fingerprint)
2547
+ warning["stages"] = [stage]
2548
+ warnings.append(warning)
2549
+ return warnings
2550
+
2551
+
2552
+ def merge_runtime_warnings(*collections: list[dict]) -> list[dict]:
2553
+ """Deduplicate the same warning while retaining every stage that emitted it."""
2554
+ merged: list[dict] = []
2555
+ indexes: dict[str, int] = {}
2556
+ for collection in collections:
2557
+ for value in collection:
2558
+ warning = dict(value)
2559
+ stages = warning.pop("stages", [])
2560
+ fingerprint = json.dumps(warning, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2561
+ if fingerprint not in indexes:
2562
+ warning["stages"] = []
2563
+ indexes[fingerprint] = len(merged)
2564
+ merged.append(warning)
2565
+ target = merged[indexes[fingerprint]]["stages"]
2566
+ for stage in stages:
2567
+ if stage not in target:
2568
+ target.append(stage)
2569
+ return merged
2570
+
2571
+
2572
+ def runtime_warning_codes(warnings: list[dict]) -> list[str]:
2573
+ return sorted({str(item["code"]) for item in warnings if item.get("code")})
2574
+
2575
+
2057
2576
  def response_field_count(payload: dict) -> int | None:
2058
2577
  for item in nested_data_objects(payload):
2059
2578
  fields = item.get("fields")
@@ -2104,8 +2623,18 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2104
2623
  ["query", "validate", query_model, "--payload", str(payload_path)], "query validate", timeout=90,
2105
2624
  )
2106
2625
  atomic_json(evidence_dir / "query-validate.json", validated)
2626
+ validation_warnings = runtime_warnings(validated, "query-validate")
2627
+ validation_warning_codes = runtime_warning_codes(validation_warnings)
2107
2628
  if not args.execute:
2108
- mark_step(state, "semanticVerified", "waiting-for-query-execution", queryModel=query_model, queryValidated=True)
2629
+ mark_step(
2630
+ state,
2631
+ "semanticVerified",
2632
+ "waiting-for-query-execution",
2633
+ queryModel=query_model,
2634
+ queryValidated=True,
2635
+ warningCount=len(validation_warnings),
2636
+ warningCodes=validation_warning_codes,
2637
+ )
2109
2638
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
2110
2639
  path = write_onboarding_state(data_root, state)
2111
2640
  return {
@@ -2116,6 +2645,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2116
2645
  "queryModel": query_model,
2117
2646
  "queryValidated": True,
2118
2647
  "queryExecuted": False,
2648
+ "warningCount": len(validation_warnings),
2649
+ "warningCodes": validation_warning_codes,
2650
+ "warnings": validation_warnings,
2119
2651
  "evidenceDir": str(evidence_dir),
2120
2652
  "statePath": str(path),
2121
2653
  "next": "rerun semantic-verify with --execute after approving the bounded business-data query",
@@ -2126,6 +2658,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2126
2658
  ["query", "execute", query_model, "--payload", str(payload_path)], "query execute", timeout=120,
2127
2659
  )
2128
2660
  atomic_json(evidence_dir / "query-execute.json", executed)
2661
+ execution_warnings = runtime_warnings(executed, "query-execute")
2662
+ warnings = merge_runtime_warnings(validation_warnings, execution_warnings)
2663
+ warning_codes = runtime_warning_codes(warnings)
2129
2664
  row_count = response_row_count(executed)
2130
2665
  mark_step(
2131
2666
  state,
@@ -2137,6 +2672,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2137
2672
  rowCount=row_count,
2138
2673
  queryPayloadDigest=sha256(payload_path),
2139
2674
  projectRoot=str(project_root),
2675
+ warningCount=len(warnings),
2676
+ warningCodes=warning_codes,
2677
+ warningEvidence=str(evidence_dir),
2140
2678
  )
2141
2679
  workspace_verifications = state.setdefault("workspaceVerifications", [])
2142
2680
  workspace_verifications[:] = [
@@ -2148,6 +2686,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2148
2686
  "queryModel": query_model,
2149
2687
  "queryPayloadDigest": sha256(payload_path),
2150
2688
  "rowCount": row_count,
2689
+ "warningCount": len(warnings),
2690
+ "warningCodes": warning_codes,
2691
+ "warningEvidence": str(evidence_dir),
2151
2692
  "verifiedAt": now_utc(),
2152
2693
  })
2153
2694
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
@@ -2161,6 +2702,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2161
2702
  "queryValidated": True,
2162
2703
  "queryExecuted": True,
2163
2704
  "rowCount": row_count,
2705
+ "warningCount": len(warnings),
2706
+ "warningCodes": warning_codes,
2707
+ "warnings": warnings,
2164
2708
  "evidenceDir": str(evidence_dir),
2165
2709
  "statePath": str(path),
2166
2710
  "next": "onboarding complete; begin analysis with described field names",
@@ -2242,6 +2786,9 @@ def onboarding_list_command(args: argparse.Namespace) -> dict:
2242
2786
  "completedSteps": completed,
2243
2787
  "totalSteps": len(ordered_steps),
2244
2788
  "steps": {name: steps.get(name, {"status": "pending"}) for name in ordered_steps},
2789
+ "warningCount": steps.get("semanticVerified", {}).get("warningCount", 0),
2790
+ "warningCodes": steps.get("semanticVerified", {}).get("warningCodes", []),
2791
+ "warningEvidence": steps.get("semanticVerified", {}).get("warningEvidence"),
2245
2792
  "next": next_onboarding_action(state),
2246
2793
  })
2247
2794
  except OnboardingError as exc:
@@ -2301,6 +2848,38 @@ def step_completed(state: dict, name: str) -> bool:
2301
2848
  return state.get("steps", {}).get(name, {}).get("status") == "completed"
2302
2849
 
2303
2850
 
2851
+ def reconcile_datasource_checkpoint(
2852
+ state: dict,
2853
+ install_state: dict,
2854
+ data_root: Path,
2855
+ runtime_state: dict,
2856
+ ) -> dict | None:
2857
+ """Reset dependent checkpoints when the live Runtime lost the datasource."""
2858
+ if not step_completed(state, "datasourceConfigured"):
2859
+ return None
2860
+ connection = state["connection"]
2861
+ listed = cli_json(
2862
+ install_state,
2863
+ runtime_state,
2864
+ connection["namespace"],
2865
+ ["datasources", "list"],
2866
+ "datasources list for checkpoint reconciliation",
2867
+ )
2868
+ if matching_datasource(listed, connection) is not None:
2869
+ return None
2870
+ reset_steps = ("datasourceConfigured", "datasourceVerified", "schemaDiscovered")
2871
+ for name in reset_steps:
2872
+ mark_step(state, name, "pending", reason="runtime-datasource-missing")
2873
+ state.get("artifacts", {}).pop("schemaDiscovery", None)
2874
+ write_onboarding_state(data_root, state)
2875
+ return {
2876
+ "recovered": True,
2877
+ "reason": "runtime-datasource-missing",
2878
+ "resetSteps": list(reset_steps),
2879
+ "dataSource": connection["name"],
2880
+ }
2881
+
2882
+
2304
2883
  def bound_project_roots(state: dict) -> list[Path]:
2305
2884
  values = [state.get("projectRoot"), *state.get("workspaceBindings", [])]
2306
2885
  result: list[Path] = []
@@ -2333,9 +2912,10 @@ def bind_completed_workspace(state: dict, data_root: Path, project_root: Path) -
2333
2912
 
2334
2913
 
2335
2914
  def datasource_run_command(args: argparse.Namespace) -> dict:
2336
- _install_root, install_state, data_root, _runtime_state = onboarding_context(args, require_runtime=True)
2915
+ _install_root, install_state, data_root, runtime_state = onboarding_context(args, require_runtime=True)
2337
2916
  project_root = normalized(args.project_root or Path.cwd())
2338
2917
  requested_connection = validate_connection(read_json_object(normalized(args.connection_file), "Connection plan"))
2918
+ resumable_connection = persisted_connection(requested_connection)
2339
2919
  if not requested_connection.get("profile"):
2340
2920
  raise OnboardingError("Composite datasource onboarding requires connection.profile in the approved contract")
2341
2921
  profile = requested_connection["profile"]
@@ -2349,7 +2929,7 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2349
2929
  files: list[str] = []
2350
2930
  existing = read_onboarding_state(data_root, profile, required=False)
2351
2931
  if existing:
2352
- if existing.get("connection") != requested_connection:
2932
+ if persisted_connection(existing.get("connection", {})) != resumable_connection:
2353
2933
  raise OnboardingError("Existing onboarding profile does not match the requested connection plan")
2354
2934
  adopted = bind_completed_workspace(existing, data_root, project_root)
2355
2935
  plan_result = {
@@ -2374,6 +2954,9 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2374
2954
  ))
2375
2955
  save_composite_result(evidence_dir, "01-plan.json", plan_result, files)
2376
2956
  state = read_onboarding_state(data_root, profile)
2957
+ checkpoint_recovery = reconcile_datasource_checkpoint(
2958
+ state, install_state, data_root, runtime_state,
2959
+ )
2377
2960
 
2378
2961
  if step_completed(state, "datasourceConfigured"):
2379
2962
  configured = resumed_phase(profile, "datasourceConfigured")
@@ -2382,7 +2965,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2382
2965
  else:
2383
2966
  configure_dry = datasource_configure_command(argparse.Namespace(
2384
2967
  install_root=args.install_root, data_root=args.data_root, profile=profile, apply=False, replace=False,
2968
+ runtime_password=requested_connection.get("password"),
2385
2969
  ))
2970
+ if checkpoint_recovery:
2971
+ configure_dry["checkpointRecovery"] = checkpoint_recovery
2386
2972
  save_composite_result(evidence_dir, "02-datasource-dry.json", configure_dry, files)
2387
2973
  if not args.approve_configure:
2388
2974
  return {
@@ -2397,7 +2983,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2397
2983
  }
2398
2984
  configured = datasource_configure_command(argparse.Namespace(
2399
2985
  install_root=args.install_root, data_root=args.data_root, profile=profile, apply=True, replace=False,
2986
+ runtime_password=requested_connection.get("password"),
2400
2987
  ))
2988
+ if checkpoint_recovery:
2989
+ configured["checkpointRecovery"] = checkpoint_recovery
2401
2990
  save_composite_result(evidence_dir, "03-datasource-apply.json", configured, files)
2402
2991
  state = read_onboarding_state(data_root, profile)
2403
2992
 
@@ -2662,6 +3251,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2662
3251
  "queryValidated": True,
2663
3252
  "queryExecuted": True,
2664
3253
  "rowCount": verified_step.get("rowCount"),
3254
+ "warningCount": verified_step.get("warningCount", 0),
3255
+ "warningCodes": verified_step.get("warningCodes", []),
3256
+ "warningEvidence": verified_step.get("warningEvidence"),
2665
3257
  "evidenceDir": str(evidence_dir),
2666
3258
  "evidenceFiles": files,
2667
3259
  "productionReady": False,
@@ -2684,6 +3276,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2684
3276
  "phaseStatus": "awaiting-query-execution-approval",
2685
3277
  "queryValidated": True,
2686
3278
  "queryExecuted": False,
3279
+ "warningCount": query_validated.get("warningCount", 0),
3280
+ "warningCodes": query_validated.get("warningCodes", []),
3281
+ "warnings": query_validated.get("warnings", []),
2687
3282
  "evidenceDir": str(evidence_dir),
2688
3283
  "evidenceFiles": files,
2689
3284
  "next": "rerun with all three approval flags after bounded query execution is approved",
@@ -2715,6 +3310,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2715
3310
  "queryValidated": executed["queryValidated"],
2716
3311
  "queryExecuted": executed["queryExecuted"],
2717
3312
  "rowCount": executed["rowCount"],
3313
+ "warningCount": executed.get("warningCount", 0),
3314
+ "warningCodes": executed.get("warningCodes", []),
3315
+ "warnings": executed.get("warnings", []),
2718
3316
  "evidenceDir": str(evidence_dir),
2719
3317
  "evidenceFiles": files,
2720
3318
  "productionReady": False,
@@ -2740,8 +3338,10 @@ def doctor_command(args: argparse.Namespace) -> dict:
2740
3338
  launcher_checks.append({"file": asset["file"], "present": path.is_file(), "sha256Valid": path.is_file() and sha256(path) == asset["sha256"]})
2741
3339
  launcher_ok = bool(launcher_checks) and all(item["present"] and item["sha256Valid"] for item in launcher_checks)
2742
3340
  analysis_skill_root = normalized(state.get("skills", {}).get("analysis", {}).get("path") or install_root / "skills" / "foggy-ai-analysis") if state else install_root / "skills" / "foggy-ai-analysis"
3341
+ semantic_query_skill_root = normalized(state.get("skills", {}).get("semanticQuery", {}).get("path") or install_root / "skills" / "foggy-semantic-query") if state else install_root / "skills" / "foggy-semantic-query"
2743
3342
  onboarding_skill_root = skill_root()
2744
3343
  analysis_skill = managed_skill_status(analysis_skill_root, "analysis", versions["components"]["analysisSkill"]["version"])
3344
+ semantic_query_skill = managed_skill_status(semantic_query_skill_root, "semantic-query", versions["components"]["semanticQuerySkill"]["version"])
2745
3345
  onboarding_skill = {
2746
3346
  "path": str(onboarding_skill_root),
2747
3347
  "present": (onboarding_skill_root / "SKILL.md").is_file(),
@@ -2764,6 +3364,7 @@ def doctor_command(args: argparse.Namespace) -> dict:
2764
3364
  "cli": cli_ok,
2765
3365
  "launcher": launcher_ok,
2766
3366
  "analysisSkill": analysis_skill["valid"],
3367
+ "semanticQuerySkill": semantic_query_skill["valid"],
2767
3368
  "onboardingSkill": onboarding_skill["valid"],
2768
3369
  }
2769
3370
  if args.strict_runtime:
@@ -2778,7 +3379,7 @@ def doctor_command(args: argparse.Namespace) -> dict:
2778
3379
  "java": java,
2779
3380
  "cli": cli,
2780
3381
  "launcherAssets": launcher_checks,
2781
- "skills": {"analysis": analysis_skill, "onboarding": onboarding_skill},
3382
+ "skills": {"analysis": analysis_skill, "semanticQuery": semantic_query_skill, "onboarding": onboarding_skill},
2782
3383
  "workspace": {"path": str(project_root), "mode": "dsh-session-cwd"},
2783
3384
  "runtime": runtime,
2784
3385
  "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")},
@@ -2796,7 +3397,7 @@ def uninstall_command(args: argparse.Namespace) -> dict:
2796
3397
  if install_root == data_root:
2797
3398
  raise OnboardingError("Install root and data root must be different")
2798
3399
  skills_root = install_root / "skills"
2799
- skill_targets = [skills_root / "foggy-ai-analysis"]
3400
+ skill_targets = [skills_root / "foggy-ai-analysis", skills_root / "foggy-semantic-query"]
2800
3401
  plan = {"installRoot": str(install_root), "dataRoot": str(data_root), "removeSkills": args.remove_skills, "purgeData": args.purge_data}
2801
3402
  if args.dry_run:
2802
3403
  return {"success": True, "dryRun": True, "plan": plan}
@@ -2831,7 +3432,7 @@ def build_parser() -> argparse.ArgumentParser:
2831
3432
  install.add_argument("--project-root")
2832
3433
  install.add_argument("--asset-cache-dir", action="append", default=[])
2833
3434
  install.add_argument("--replace-skill", action="store_true")
2834
- install.add_argument("--repair-component", choices=("cli", "launcher", "analysis-skill"))
3435
+ install.add_argument("--repair-component", choices=("cli", "launcher", "analysis-skill", "semantic-query-skill"))
2835
3436
  install.add_argument("--skip-cli-install", action="store_true")
2836
3437
  install.add_argument("--cli-command")
2837
3438
  install.add_argument("--progress-file")
@@ -2855,6 +3456,9 @@ def build_parser() -> argparse.ArgumentParser:
2855
3456
  start.add_argument("--namespace")
2856
3457
  start.add_argument("--timeout", type=int)
2857
3458
  start.add_argument("--java")
3459
+ start.add_argument("--progress-file")
3460
+ start.add_argument("--operation-id")
3461
+ start.add_argument("--operation-kind", default="runtime-start")
2858
3462
  start.set_defaults(handler=runtime_start_command)
2859
3463
 
2860
3464
  stop = sub.add_parser("runtime-stop")
@@ -3006,6 +3610,10 @@ def build_parser() -> argparse.ArgumentParser:
3006
3610
 
3007
3611
 
3008
3612
  def main() -> None:
3613
+ for stream in (sys.stdout, sys.stderr):
3614
+ reconfigure = getattr(stream, "reconfigure", None)
3615
+ if callable(reconfigure):
3616
+ reconfigure(encoding="utf-8", errors="replace")
3009
3617
  args = build_parser().parse_args()
3010
3618
  try:
3011
3619
  result = args.handler(args)
@@ -3016,7 +3624,7 @@ def main() -> None:
3016
3624
  except OnboardingError as exc:
3017
3625
  if ACTIVE_PROGRESS is not None:
3018
3626
  ACTIVE_PROGRESS.fail(exc)
3019
- emit({"success": False, "error": {"code": "ONBOARDING_ERROR", "message": str(exc)}, "productionReady": False}, 1)
3627
+ emit({"success": False, "error": {"code": getattr(exc, "code", "ONBOARDING_ERROR"), "message": str(exc)}, "productionReady": False}, 1)
3020
3628
  except Exception as exc:
3021
3629
  if ACTIVE_PROGRESS is not None:
3022
3630
  ACTIVE_PROGRESS.fail(exc)