@foggy-projects/deepseek-harness-plugin 0.4.0-beta.8 → 0.4.0-rc.1

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.
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import datetime as dt
8
+ import errno
8
9
  import hashlib
9
10
  import json
10
11
  import os
@@ -17,6 +18,7 @@ import subprocess
17
18
  import sys
18
19
  import tempfile
19
20
  import time
21
+ import urllib.error
20
22
  import urllib.request
21
23
  import venv
22
24
  import zipfile
@@ -24,6 +26,8 @@ import zipfile
24
26
 
25
27
  STATE_SCHEMA = "foggy-deepseek-onboarding-install/v1"
26
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"
27
31
  ONBOARDING_STATE_SCHEMA = "foggy-deepseek-onboarding-state/v1"
28
32
  MANAGED_SKILL_SCHEMA = "foggy-managed-skill/v1"
29
33
  MANAGED_SKILL_MARKER = ".foggy-managed-skill.json"
@@ -42,25 +46,48 @@ class OnboardingError(RuntimeError):
42
46
  pass
43
47
 
44
48
 
49
+ class RuntimeSettingsError(OnboardingError):
50
+ code = "RUNTIME_SETTINGS_INVALID"
51
+
52
+
53
+ class RuntimePortUnavailableError(OnboardingError):
54
+ code = "RUNTIME_PORT_UNAVAILABLE"
55
+
56
+
45
57
  PROGRESS_SCHEMA = "foggy-deepseek-onboarding-progress/v1"
46
58
  ACTIVE_PROGRESS: "ProgressReporter | None" = None
47
59
 
48
60
 
49
61
  class ProgressReporter:
50
- total_steps = 7
51
-
52
- 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:
53
73
  self.path = Path(path).expanduser() if path else None
54
74
  self.operation_id = operation_id
55
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
56
80
  self.started_at = now_utc()
57
81
  self.phase = "preflight"
58
82
  self.step_index = 0
59
83
  self.fraction = 0.0
84
+ self.percent_override: int | None = None
60
85
  self.message = ""
61
86
  self.current_file: str | None = None
62
87
  self.completed_files: int | None = None
63
88
  self.total_files: int | None = None
89
+ self.elapsed_seconds: int | None = None
90
+ self.timeout_seconds: int | None = None
64
91
 
65
92
  @property
66
93
  def enabled(self) -> bool:
@@ -71,7 +98,11 @@ class ProgressReporter:
71
98
  return
72
99
  bounded_step = max(0, min(self.step_index, self.total_steps))
73
100
  bounded_fraction = max(0.0, min(float(self.fraction), 1.0))
74
- 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
+ )
75
106
  payload = {
76
107
  "schemaVersion": PROGRESS_SCHEMA,
77
108
  "operationId": self.operation_id,
@@ -87,6 +118,10 @@ class ProgressReporter:
87
118
  }
88
119
  if self.completed_files is not None and self.total_files is not None:
89
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
90
125
  if finished_at:
91
126
  payload["finishedAt"] = finished_at
92
127
  if error:
@@ -104,28 +139,37 @@ class ProgressReporter:
104
139
  current_file: str | None = None,
105
140
  completed_files: int | None = None,
106
141
  total_files: int | None = None,
142
+ percent: int | None = None,
143
+ elapsed_seconds: int | None = None,
144
+ timeout_seconds: int | None = None,
107
145
  ) -> None:
108
146
  self.phase = phase
109
147
  self.step_index = step_index
110
148
  self.fraction = fraction
149
+ self.percent_override = percent
111
150
  self.message = message
112
151
  self.current_file = current_file
113
152
  self.completed_files = completed_files
114
153
  self.total_files = total_files
154
+ self.elapsed_seconds = elapsed_seconds
155
+ self.timeout_seconds = timeout_seconds
115
156
  self._write("running")
116
157
 
117
158
  def finish(self) -> None:
118
- self.phase = "complete"
159
+ self.phase = self.completion_phase
119
160
  self.step_index = self.total_steps
120
161
  self.fraction = 0.0
121
- self.message = "Foggy initialization completed"
162
+ self.percent_override = 100
163
+ self.message = self.completion_message
122
164
  self.current_file = None
123
165
  self.completed_files = None
124
166
  self.total_files = None
167
+ self.elapsed_seconds = None
168
+ self.timeout_seconds = None
125
169
  self._write("succeeded", finished_at=now_utc())
126
170
 
127
171
  def fail(self, error: object) -> None:
128
- self.message = "Foggy initialization failed"
172
+ self.message = self.failure_message
129
173
  self._write("failed", finished_at=now_utc(), error=str(error))
130
174
 
131
175
 
@@ -173,6 +217,61 @@ def default_data_root() -> Path:
173
217
  return Path(base) / "foggy" / "deepseek-harness" if base else Path.home() / ".local" / "state" / "foggy" / "deepseek-harness"
174
218
 
175
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
+
176
275
  def normalized(path: str | Path) -> Path:
177
276
  return Path(path).expanduser().resolve(strict=False)
178
277
 
@@ -244,6 +343,128 @@ def command_result(command: list[str], timeout: int = 30, check: bool = False) -
244
343
  return payload
245
344
 
246
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
+
247
468
  def sha256(path: Path) -> str:
248
469
  digest = hashlib.sha256()
249
470
  with path.open("rb") as stream:
@@ -337,11 +558,39 @@ def venv_cli(install_root: Path) -> Path:
337
558
  return install_root / "venv" / ("Scripts/foggy-runtime.exe" if os.name == "nt" else "bin/foggy-runtime")
338
559
 
339
560
 
561
+ def replace_with_retry(source: Path, destination: Path, attempts: int = 12) -> None:
562
+ transient_errno = {errno.EACCES, errno.EBUSY, errno.EPERM}
563
+ transient_winerror = {5, 32, 33}
564
+ for attempt in range(attempts):
565
+ try:
566
+ os.replace(source, destination)
567
+ return
568
+ except OSError as exc:
569
+ transient = exc.errno in transient_errno or getattr(exc, "winerror", None) in transient_winerror
570
+ if not transient or attempt == attempts - 1:
571
+ raise
572
+ time.sleep(min(0.025 * (2 ** attempt), 0.4))
573
+
574
+
340
575
  def atomic_json(path: Path, payload: dict) -> None:
341
576
  path.parent.mkdir(parents=True, exist_ok=True)
342
- temporary = path.with_name(path.name + ".tmp")
343
- temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
344
- os.replace(temporary, path)
577
+ handle = tempfile.NamedTemporaryFile(
578
+ mode="w",
579
+ encoding="utf-8",
580
+ dir=path.parent,
581
+ prefix=f".{path.name}.",
582
+ suffix=".tmp",
583
+ delete=False,
584
+ )
585
+ temporary = Path(handle.name)
586
+ try:
587
+ with handle:
588
+ json.dump(payload, handle, indent=2, ensure_ascii=False)
589
+ handle.flush()
590
+ os.fsync(handle.fileno())
591
+ replace_with_retry(temporary, path)
592
+ finally:
593
+ temporary.unlink(missing_ok=True)
345
594
 
346
595
 
347
596
  def skill_tree_digest(root: Path) -> str:
@@ -670,7 +919,7 @@ def validate_connection(payload: dict) -> dict:
670
919
  if payload.get("schemaVersion") != CONNECTION_SCHEMA:
671
920
  raise OnboardingError(f"connection schemaVersion must be {CONNECTION_SCHEMA}")
672
921
  allowed = {
673
- "schemaVersion", "name", "type", "jdbcUrl", "username", "passwordEnv",
922
+ "schemaVersion", "name", "type", "jdbcUrl", "username", "password", "passwordEnv",
674
923
  "opaqueProfileId", "opaqueRevision",
675
924
  "profile", "namespace", "schemas", "modelsDir", "evidenceDir", "readOnlyRecommended",
676
925
  }
@@ -682,6 +931,7 @@ def validate_connection(payload: dict) -> dict:
682
931
  for name in required:
683
932
  if not isinstance(payload.get(name), str) or not payload[name].strip():
684
933
  raise OnboardingError(f"connection.{name} must be a non-empty string")
934
+ password = payload.get("password")
685
935
  password_env = payload.get("passwordEnv")
686
936
  jdbc_url = None
687
937
  if opaque:
@@ -689,15 +939,19 @@ def validate_connection(payload: dict) -> dict:
689
939
  raise OnboardingError("connection.opaqueProfileId must be an opaque Foggy profile ID")
690
940
  if not isinstance(payload.get("opaqueRevision"), str) or not OPAQUE_REVISION_PATTERN.fullmatch(payload["opaqueRevision"]):
691
941
  raise OnboardingError("connection.opaqueRevision must be a sha256 revision")
692
- 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)
693
943
  if exposed:
694
944
  raise OnboardingError(f"Opaque connection plans must not contain: {', '.join(exposed)}")
695
945
  else:
946
+ if password is not None and not isinstance(password, str):
947
+ raise OnboardingError("connection.password must be a string")
696
948
  if password_env is not None and (not isinstance(password_env, str) or not ENV_NAME_PATTERN.fullmatch(password_env)):
697
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")
698
952
  jdbc_url = payload["jdbcUrl"].strip()
699
953
  if re.search(r"(?i)(?:password|passwd|pwd)\s*=", jdbc_url) or re.search(r"//[^/@:]+:[^/@]+@", jdbc_url):
700
- raise OnboardingError("Do not embed passwords in jdbcUrl; use passwordEnv")
954
+ raise OnboardingError("Do not embed passwords in jdbcUrl; use password or passwordEnv")
701
955
  schemas = payload.get("schemas", [])
702
956
  if not isinstance(schemas, list) or any(not isinstance(item, str) or not item.strip() for item in schemas):
703
957
  raise OnboardingError("connection.schemas must be an array of non-empty strings")
@@ -725,6 +979,7 @@ def validate_connection(payload: dict) -> dict:
725
979
  else:
726
980
  result["jdbcUrl"] = jdbc_url
727
981
  result["username"] = payload.get("username")
982
+ result["password"] = password
728
983
  result["passwordEnv"] = password_env
729
984
  if payload.get("profile") is not None:
730
985
  result["profile"] = safe_profile(payload["profile"])
@@ -732,11 +987,24 @@ def validate_connection(payload: dict) -> dict:
732
987
  result["evidenceDir"] = payload["evidenceDir"].strip()
733
988
  if result["type"] not in {"sqlite", "mysql", "postgres", "postgresql"}:
734
989
  raise OnboardingError("Initial onboarding supports sqlite, mysql, postgres, and postgresql")
735
- if not opaque and result["type"] != "sqlite" and not result["passwordEnv"]:
736
- raise OnboardingError("Non-SQLite connections require passwordEnv")
737
990
  return result
738
991
 
739
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
+
740
1008
  def validate_semantic_plan(payload: dict) -> dict:
741
1009
  if payload.get("schemaVersion") != SEMANTIC_PLAN_SCHEMA:
742
1010
  raise OnboardingError(f"semantic plan schemaVersion must be {SEMANTIC_PLAN_SCHEMA}")
@@ -1247,8 +1515,57 @@ def parse_json_output(result: dict, label: str) -> dict:
1247
1515
  raise OnboardingError(f"{label} did not return JSON") from exc
1248
1516
 
1249
1517
 
1518
+ def write_runtime_start_failure(
1519
+ data_root: Path,
1520
+ evidence_dir: Path,
1521
+ launch: dict,
1522
+ phase: str,
1523
+ error: object,
1524
+ elapsed_ms: int,
1525
+ ) -> dict:
1526
+ logs = []
1527
+ for name in ("stdoutLog", "stderrLog"):
1528
+ value = launch.get(name)
1529
+ path = normalized(value) if isinstance(value, str) and value else None
1530
+ item = {"name": name, "path": str(path) if path else None, "exists": bool(path and path.is_file())}
1531
+ if path and path.is_file():
1532
+ stat = path.stat()
1533
+ item.update({"sizeBytes": stat.st_size, "modifiedAt": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat()})
1534
+ logs.append(item)
1535
+ pid = int(launch.get("pid", 0))
1536
+ failure = {
1537
+ "schemaVersion": "foggy-deepseek-runtime-start-failure/v1",
1538
+ "failedAt": now_utc(),
1539
+ "phase": phase,
1540
+ "message": str(error),
1541
+ "elapsedMs": elapsed_ms,
1542
+ "pid": pid or None,
1543
+ "processRunningBeforeCleanup": process_info(pid)["running"] if pid else False,
1544
+ "runtimeUrl": launch.get("runtimeUrl"),
1545
+ "workDir": launch.get("workDir"),
1546
+ "evidenceDir": str(evidence_dir),
1547
+ "logs": logs,
1548
+ }
1549
+ atomic_json(evidence_dir / "failure.json", failure)
1550
+ atomic_json(data_root / "last-runtime-start-failure.json", failure)
1551
+ return failure
1552
+
1553
+
1250
1554
  def runtime_start_command(args: argparse.Namespace) -> dict:
1555
+ global ACTIVE_PROGRESS
1251
1556
  versions = load_versions()
1557
+ readiness_timeout = args.timeout or int(versions["defaults"]["readinessTimeoutSeconds"])
1558
+ progress = ProgressReporter(
1559
+ getattr(args, "progress_file", None),
1560
+ getattr(args, "operation_id", None),
1561
+ getattr(args, "operation_kind", "runtime-start"),
1562
+ total_steps=6,
1563
+ completion_phase="runtime-complete",
1564
+ completion_message="Foggy Runtime is ready",
1565
+ failure_message="Foggy Runtime startup failed",
1566
+ )
1567
+ ACTIVE_PROGRESS = progress
1568
+ progress.update("runtime-preflight", 0, "Checking Runtime state and prerequisites", percent=3)
1252
1569
  install_root = normalized(args.install_root or default_install_root())
1253
1570
  state = read_install_state(install_root)
1254
1571
  data_root = normalized(args.data_root or state["dataRoot"])
@@ -1267,15 +1584,25 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1267
1584
  base_url = prior.get("runtimeUrl")
1268
1585
  if not isinstance(base_url, str) or not base_url:
1269
1586
  raise OnboardingError("Recorded Runtime does not contain runtimeUrl")
1270
- wait_result = command_result(
1587
+ progress.update("runtime-readiness", 3, "Revalidating the recorded Runtime", percent=30, elapsed_seconds=0, timeout_seconds=readiness_timeout)
1588
+ wait_result = command_result_with_progress(
1271
1589
  [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready",
1272
- "--timeout-seconds", str(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]),
1590
+ "--timeout-seconds", str(readiness_timeout),
1273
1591
  "--interval-seconds", "1"],
1274
- timeout=(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]) + 30,
1592
+ readiness_timeout + 30,
1593
+ progress,
1594
+ phase="runtime-readiness",
1595
+ step_index=3,
1596
+ message="Revalidating the recorded Runtime",
1597
+ readiness_timeout=readiness_timeout,
1598
+ start_percent=30,
1599
+ end_percent=85,
1600
+ watched_pid=int(prior["pid"]),
1275
1601
  )
1276
1602
  wait_payload = parse_json_output(wait_result, "wait-ready")
1277
1603
  if wait_payload.get("success") is not True:
1278
1604
  raise OnboardingError("wait-ready returned success=false for recorded Runtime")
1605
+ progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
1279
1606
  capabilities = parse_json_output(
1280
1607
  command_result(
1281
1608
  [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"],
@@ -1295,6 +1622,8 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1295
1622
  "schemaVersion": capabilities.get("data", {}).get("schemaVersion"),
1296
1623
  "securityMode": capabilities.get("data", {}).get("securityMode"),
1297
1624
  }
1625
+ progress.finish()
1626
+ ACTIVE_PROGRESS = None
1298
1627
  return {
1299
1628
  "success": True,
1300
1629
  **prior,
@@ -1311,16 +1640,15 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1311
1640
  "productionReady": False,
1312
1641
  }
1313
1642
  existing.unlink()
1314
- port = args.port or int(versions["defaults"]["port"])
1315
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
1316
- try:
1317
- probe.bind(("127.0.0.1", port))
1318
- except OSError as exc:
1319
- raise OnboardingError(f"Port {port} is not available") from exc
1643
+ progress.update("runtime-preflight", 1, "Checking port and Runtime workspace", percent=10)
1644
+ configured_runtime = read_runtime_settings(data_root, versions["defaults"]["port"])
1645
+ port = validate_runtime_port(args.port) if args.port is not None else configured_runtime["port"]
1646
+ assert_runtime_port_available(data_root, port)
1320
1647
  work_dir = data_root / "runtime"
1321
1648
  work_dir.mkdir(parents=True, exist_ok=True)
1322
1649
  launcher_dir = normalized(state["launcher"]["path"])
1323
1650
  java_exe = args.java or os.environ.get("JAVA_EXE", "java")
1651
+ progress.update("runtime-launcher", 2, "Starting the Java Runtime through Launcher", percent=20)
1324
1652
  if os.name == "nt":
1325
1653
  shell = shutil.which("pwsh") or shutil.which("powershell")
1326
1654
  if not shell:
@@ -1368,12 +1696,26 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1368
1696
  evidence_dir = data_root / "evidence" / f"runtime-start-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
1369
1697
  evidence_dir.mkdir(parents=True, exist_ok=True)
1370
1698
  atomic_json(evidence_dir / "launch.json", launch)
1699
+ startup_started = time.monotonic()
1371
1700
  try:
1372
- 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)
1701
+ progress.update("runtime-readiness", 3, "Waiting for Java Runtime readiness", percent=30, elapsed_seconds=0, timeout_seconds=readiness_timeout)
1702
+ wait_result = command_result_with_progress(
1703
+ [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready", "--timeout-seconds", str(readiness_timeout), "--interval-seconds", "1"],
1704
+ readiness_timeout + 30,
1705
+ progress,
1706
+ phase="runtime-readiness",
1707
+ step_index=3,
1708
+ message="Waiting for Java Runtime readiness",
1709
+ readiness_timeout=readiness_timeout,
1710
+ start_percent=30,
1711
+ end_percent=85,
1712
+ watched_pid=pid,
1713
+ )
1373
1714
  wait_payload = parse_json_output(wait_result, "wait-ready")
1374
1715
  if wait_payload.get("success") is not True:
1375
1716
  raise OnboardingError("wait-ready returned success=false")
1376
1717
  atomic_json(evidence_dir / "wait-ready.json", wait_payload)
1718
+ progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
1377
1719
  capabilities_result = command_result([cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"], timeout=30)
1378
1720
  capabilities = parse_json_output(capabilities_result, "capabilities")
1379
1721
  expected_contract = versions["components"]["launcher"]["runtimeApiContract"]
@@ -1382,12 +1724,21 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1382
1724
  if capabilities.get("data", {}).get("securityMode") != versions["defaults"]["securityMode"]:
1383
1725
  raise OnboardingError("Packaged Launcher did not report the expected dev/test security mode")
1384
1726
  atomic_json(evidence_dir / "capabilities.json", capabilities)
1385
- except Exception:
1727
+ except Exception as exc:
1728
+ failure = write_runtime_start_failure(
1729
+ data_root,
1730
+ evidence_dir,
1731
+ launch,
1732
+ progress.phase,
1733
+ exc,
1734
+ round((time.monotonic() - startup_started) * 1000),
1735
+ )
1386
1736
  try:
1387
1737
  os.kill(pid, signal.SIGTERM)
1388
1738
  except OSError:
1389
1739
  pass
1390
- raise
1740
+ raise OnboardingError(f"{exc}; startup diagnostics: {failure['evidenceDir']}") from exc
1741
+ progress.update("runtime-state", 5, "Saving verified Runtime state", percent=96, current_file="runtime-state.json")
1391
1742
  runtime_state = {
1392
1743
  "schemaVersion": RUNTIME_STATE_SCHEMA,
1393
1744
  "startedAt": now_utc(),
@@ -1406,6 +1757,9 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1406
1757
  },
1407
1758
  }
1408
1759
  atomic_json(data_root / "runtime-state.json", runtime_state)
1760
+ (data_root / "last-runtime-start-failure.json").unlink(missing_ok=True)
1761
+ progress.finish()
1762
+ ACTIVE_PROGRESS = None
1409
1763
  return {"success": True, **runtime_state, "productionReady": False}
1410
1764
 
1411
1765
 
@@ -1446,6 +1800,108 @@ def cli_json(install_state: dict, runtime_state: dict, namespace: str, command:
1446
1800
  return payload
1447
1801
 
1448
1802
 
1803
+ TRANSIENT_DATASOURCE_TEST_MARKERS = (
1804
+ "connection is not available",
1805
+ "connection pool",
1806
+ "request timed out",
1807
+ "timeout waiting for connection",
1808
+ )
1809
+
1810
+
1811
+ def datasource_test_with_retry(
1812
+ install_state: dict,
1813
+ runtime_state: dict,
1814
+ namespace: str,
1815
+ datasource_name: str,
1816
+ *,
1817
+ attempts: int = 3,
1818
+ initial_delay_seconds: float = 1.0,
1819
+ ) -> dict:
1820
+ """Retry only transient pool/readiness failures; authentication and configuration failures fail fast."""
1821
+ for attempt in range(1, attempts + 1):
1822
+ try:
1823
+ result = cli_json(
1824
+ install_state,
1825
+ runtime_state,
1826
+ namespace,
1827
+ ["datasources", "test", datasource_name],
1828
+ "datasources test",
1829
+ )
1830
+ if attempt > 1:
1831
+ result = {
1832
+ **result,
1833
+ "onboardingRetry": {
1834
+ "attempts": attempt,
1835
+ "reason": "transient-datasource-readiness",
1836
+ },
1837
+ }
1838
+ return result
1839
+ except OnboardingError as exc:
1840
+ transient = any(marker in str(exc).lower() for marker in TRANSIENT_DATASOURCE_TEST_MARKERS)
1841
+ if not transient or attempt == attempts:
1842
+ raise
1843
+ time.sleep(initial_delay_seconds * attempt)
1844
+ raise AssertionError("unreachable")
1845
+
1846
+
1847
+ def runtime_api_json(
1848
+ runtime_state: dict,
1849
+ namespace: str,
1850
+ method: str,
1851
+ path: str,
1852
+ body: dict | None,
1853
+ label: str,
1854
+ timeout: int = 60,
1855
+ ) -> dict:
1856
+ """Call the public Runtime API for development inputs not yet exposed by the pinned CLI."""
1857
+ base_url = str(runtime_state["runtimeUrl"]).rstrip("/")
1858
+ request_body = json.dumps(body).encode("utf-8") if body is not None else None
1859
+ headers = {
1860
+ "Accept": "application/json",
1861
+ "Content-Type": "application/json",
1862
+ "X-NS": namespace,
1863
+ }
1864
+ auth_code = os.environ.get("FOGGY_RUNTIME_API_AUTH_CODE")
1865
+ if auth_code:
1866
+ headers["X-Foggy-Runtime-Code"] = auth_code
1867
+ request = urllib.request.Request(
1868
+ f"{base_url}{path}",
1869
+ data=request_body,
1870
+ headers=headers,
1871
+ method=method,
1872
+ )
1873
+ try:
1874
+ with urllib.request.urlopen(request, timeout=timeout) as response:
1875
+ raw = response.read().decode("utf-8", errors="replace")
1876
+ except urllib.error.HTTPError as exc:
1877
+ raw = exc.read().decode("utf-8", errors="replace")
1878
+ try:
1879
+ failed = json.loads(raw)
1880
+ except json.JSONDecodeError:
1881
+ failed = None
1882
+ error = failed.get("error") if isinstance(failed, dict) else None
1883
+ detail = (
1884
+ " | ".join(str(error.get(name)) for name in ("code", "phase", "message") if error.get(name))
1885
+ if isinstance(error, dict)
1886
+ else str(exc.reason)
1887
+ )
1888
+ raise OnboardingError(f"{label} failed with HTTP {exc.code}: {detail or exc.reason}") from exc
1889
+ except urllib.error.URLError as exc:
1890
+ raise OnboardingError(f"{label} could not reach Runtime: {exc.reason}") from exc
1891
+ try:
1892
+ payload = json.loads(raw)
1893
+ except json.JSONDecodeError as exc:
1894
+ raise OnboardingError(f"{label} did not return JSON") from exc
1895
+ if payload.get("success") is not True:
1896
+ error = payload.get("error")
1897
+ if isinstance(error, dict):
1898
+ detail = " | ".join(str(error.get(name)) for name in ("code", "phase", "message") if error.get(name))
1899
+ else:
1900
+ detail = str(error or "unknown Runtime error")
1901
+ raise OnboardingError(f"{label} returned success=false: {detail}")
1902
+ return payload
1903
+
1904
+
1449
1905
  CONNECTION_SECRET_KEYS = {
1450
1906
  "jdbcurl", "url", "username", "password", "passwordenv", "passwordref",
1451
1907
  }
@@ -1503,7 +1959,8 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
1503
1959
  if not project_root.is_dir():
1504
1960
  raise OnboardingError(f"Project root not found: {project_root}")
1505
1961
  connection_file = normalized(args.connection_file)
1506
- connection = validate_connection(read_json_object(connection_file, "Connection plan"))
1962
+ requested_connection = validate_connection(read_json_object(connection_file, "Connection plan"))
1963
+ connection = persisted_connection(requested_connection)
1507
1964
  if connection.get("connectionMode") == "opaque-profile":
1508
1965
  require_opaque_profile_cli(install_state)
1509
1966
  existing = read_onboarding_state(data_root, profile, required=False)
@@ -1548,7 +2005,10 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
1548
2005
  "schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
1549
2006
  "profile": profile,
1550
2007
  "statePath": str(path),
1551
- "connection": {**connection, "passwordEnvPresent": bool(password_env and os.environ.get(password_env))},
2008
+ "connection": {
2009
+ **connection,
2010
+ "passwordEnvPresent": bool(password_env and os.environ.get(password_env)),
2011
+ },
1552
2012
  "runtimeAvailable": runtime_state is not None,
1553
2013
  "next": "run datasource-configure --apply after reviewing the plan",
1554
2014
  "productionReady": False,
@@ -1579,10 +2039,11 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
1579
2039
  if opaque:
1580
2040
  plan.update({"profileId": connection["opaqueProfileId"], "revision": connection["opaqueRevision"]})
1581
2041
  else:
2042
+ inline_password = getattr(args, "runtime_password", None)
1582
2043
  plan.update({
1583
2044
  "jdbcUrl": connection["jdbcUrl"],
1584
2045
  "username": connection.get("username"),
1585
- "passwordEnv": connection.get("passwordEnv"),
2046
+ "credentialMode": connection.get("credentialMode", "none"),
1586
2047
  })
1587
2048
  if not args.apply:
1588
2049
  return {"success": True, "dryRun": True, "profile": state["profile"], "plan": plan, "next": "rerun with --apply after approval"}
@@ -1594,22 +2055,50 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
1594
2055
  ]
1595
2056
  label = "opaque profile configure"
1596
2057
  else:
2058
+ inline_password = getattr(args, "runtime_password", None)
1597
2059
  password_env = connection.get("passwordEnv")
1598
- if password_env and not os.environ.get(password_env):
2060
+ if connection.get("credentialMode") == "inline-development" and inline_password is None:
2061
+ raise OnboardingError(
2062
+ "Direct development password is not persisted; rerun onboard-datasource-run with the original connection file"
2063
+ )
2064
+ if inline_password is None and password_env and os.environ.get(password_env) is None:
1599
2065
  raise OnboardingError(f"Required password environment variable is not present: {password_env}")
1600
- command = ["datasources", "add", "--name", connection["name"], "--type", connection["type"], "--jdbc-url", connection["jdbcUrl"]]
1601
- if connection.get("username"):
1602
- command.extend(["--username", connection["username"]])
1603
- if password_env:
1604
- command.extend(["--password-env", password_env])
1605
2066
  label = "datasources add"
1606
- if args.replace:
1607
- command.append("--replace")
2067
+ resolved_password = inline_password if inline_password is not None else (
2068
+ os.environ.get(password_env) if password_env else None
2069
+ )
2070
+ command = None
1608
2071
  already_present = False
1609
2072
  try:
1610
- result = redact_connection_material(
1611
- cli_json(install_state, runtime_state, connection["namespace"], command, label)
1612
- )
2073
+ if not opaque and resolved_password is not None:
2074
+ body = {
2075
+ "name": connection["name"],
2076
+ "type": connection["type"],
2077
+ "jdbcUrl": connection["jdbcUrl"],
2078
+ "replace": bool(args.replace),
2079
+ "enabled": True,
2080
+ "password": resolved_password,
2081
+ }
2082
+ if connection.get("username"):
2083
+ body["username"] = connection["username"]
2084
+ result = redact_connection_material(runtime_api_json(
2085
+ runtime_state,
2086
+ connection["namespace"],
2087
+ "POST",
2088
+ "/api/v1/datasources",
2089
+ body,
2090
+ label,
2091
+ ))
2092
+ else:
2093
+ if not opaque:
2094
+ command = ["datasources", "add", "--name", connection["name"], "--type", connection["type"], "--jdbc-url", connection["jdbcUrl"]]
2095
+ if connection.get("username"):
2096
+ command.extend(["--username", connection["username"]])
2097
+ if args.replace:
2098
+ command.append("--replace")
2099
+ result = redact_connection_material(
2100
+ cli_json(install_state, runtime_state, connection["namespace"], command, label)
2101
+ )
1613
2102
  except OnboardingError as exc:
1614
2103
  if "DATASOURCE_ALREADY_EXISTS" not in str(exc):
1615
2104
  raise
@@ -1650,9 +2139,8 @@ def datasource_verify_command(args: argparse.Namespace) -> dict:
1650
2139
  raise OnboardingError("Datasource is not configured; run datasource-configure --apply first")
1651
2140
  connection = state["connection"]
1652
2141
  namespace = connection["namespace"]
1653
- tested = redact_connection_material(cli_json(
1654
- install_state, runtime_state, namespace,
1655
- ["datasources", "test", connection["name"]], "datasources test",
2142
+ tested = redact_connection_material(datasource_test_with_retry(
2143
+ install_state, runtime_state, namespace, connection["name"],
1656
2144
  ))
1657
2145
  if not args.bind:
1658
2146
  mark_step(state, "datasourceVerified", "waiting-for-binding", connectionTested=True)
@@ -2025,6 +2513,52 @@ def nested_data_objects(payload: dict) -> list[dict]:
2025
2513
  return objects
2026
2514
 
2027
2515
 
2516
+ def runtime_warnings(payload: dict, stage: str) -> list[dict]:
2517
+ """Collect structured query-input and legacy warnings without inventing remediation."""
2518
+ warnings: list[dict] = []
2519
+ seen: set[str] = set()
2520
+ for item in nested_data_objects(payload):
2521
+ for field, kind in (("queryInputWarnings", "query-input"), ("warnings", "legacy")):
2522
+ raw = item.get(field)
2523
+ if raw is None:
2524
+ continue
2525
+ values = raw if isinstance(raw, list) else [raw]
2526
+ for value in values:
2527
+ warning = dict(value) if isinstance(value, dict) else {"message": str(value)}
2528
+ warning["warningKind"] = kind
2529
+ fingerprint = json.dumps(warning, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2530
+ if fingerprint in seen:
2531
+ continue
2532
+ seen.add(fingerprint)
2533
+ warning["stages"] = [stage]
2534
+ warnings.append(warning)
2535
+ return warnings
2536
+
2537
+
2538
+ def merge_runtime_warnings(*collections: list[dict]) -> list[dict]:
2539
+ """Deduplicate the same warning while retaining every stage that emitted it."""
2540
+ merged: list[dict] = []
2541
+ indexes: dict[str, int] = {}
2542
+ for collection in collections:
2543
+ for value in collection:
2544
+ warning = dict(value)
2545
+ stages = warning.pop("stages", [])
2546
+ fingerprint = json.dumps(warning, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2547
+ if fingerprint not in indexes:
2548
+ warning["stages"] = []
2549
+ indexes[fingerprint] = len(merged)
2550
+ merged.append(warning)
2551
+ target = merged[indexes[fingerprint]]["stages"]
2552
+ for stage in stages:
2553
+ if stage not in target:
2554
+ target.append(stage)
2555
+ return merged
2556
+
2557
+
2558
+ def runtime_warning_codes(warnings: list[dict]) -> list[str]:
2559
+ return sorted({str(item["code"]) for item in warnings if item.get("code")})
2560
+
2561
+
2028
2562
  def response_field_count(payload: dict) -> int | None:
2029
2563
  for item in nested_data_objects(payload):
2030
2564
  fields = item.get("fields")
@@ -2075,8 +2609,18 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2075
2609
  ["query", "validate", query_model, "--payload", str(payload_path)], "query validate", timeout=90,
2076
2610
  )
2077
2611
  atomic_json(evidence_dir / "query-validate.json", validated)
2612
+ validation_warnings = runtime_warnings(validated, "query-validate")
2613
+ validation_warning_codes = runtime_warning_codes(validation_warnings)
2078
2614
  if not args.execute:
2079
- mark_step(state, "semanticVerified", "waiting-for-query-execution", queryModel=query_model, queryValidated=True)
2615
+ mark_step(
2616
+ state,
2617
+ "semanticVerified",
2618
+ "waiting-for-query-execution",
2619
+ queryModel=query_model,
2620
+ queryValidated=True,
2621
+ warningCount=len(validation_warnings),
2622
+ warningCodes=validation_warning_codes,
2623
+ )
2080
2624
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
2081
2625
  path = write_onboarding_state(data_root, state)
2082
2626
  return {
@@ -2087,6 +2631,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2087
2631
  "queryModel": query_model,
2088
2632
  "queryValidated": True,
2089
2633
  "queryExecuted": False,
2634
+ "warningCount": len(validation_warnings),
2635
+ "warningCodes": validation_warning_codes,
2636
+ "warnings": validation_warnings,
2090
2637
  "evidenceDir": str(evidence_dir),
2091
2638
  "statePath": str(path),
2092
2639
  "next": "rerun semantic-verify with --execute after approving the bounded business-data query",
@@ -2097,6 +2644,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2097
2644
  ["query", "execute", query_model, "--payload", str(payload_path)], "query execute", timeout=120,
2098
2645
  )
2099
2646
  atomic_json(evidence_dir / "query-execute.json", executed)
2647
+ execution_warnings = runtime_warnings(executed, "query-execute")
2648
+ warnings = merge_runtime_warnings(validation_warnings, execution_warnings)
2649
+ warning_codes = runtime_warning_codes(warnings)
2100
2650
  row_count = response_row_count(executed)
2101
2651
  mark_step(
2102
2652
  state,
@@ -2108,6 +2658,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2108
2658
  rowCount=row_count,
2109
2659
  queryPayloadDigest=sha256(payload_path),
2110
2660
  projectRoot=str(project_root),
2661
+ warningCount=len(warnings),
2662
+ warningCodes=warning_codes,
2663
+ warningEvidence=str(evidence_dir),
2111
2664
  )
2112
2665
  workspace_verifications = state.setdefault("workspaceVerifications", [])
2113
2666
  workspace_verifications[:] = [
@@ -2119,6 +2672,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2119
2672
  "queryModel": query_model,
2120
2673
  "queryPayloadDigest": sha256(payload_path),
2121
2674
  "rowCount": row_count,
2675
+ "warningCount": len(warnings),
2676
+ "warningCodes": warning_codes,
2677
+ "warningEvidence": str(evidence_dir),
2122
2678
  "verifiedAt": now_utc(),
2123
2679
  })
2124
2680
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
@@ -2132,6 +2688,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2132
2688
  "queryValidated": True,
2133
2689
  "queryExecuted": True,
2134
2690
  "rowCount": row_count,
2691
+ "warningCount": len(warnings),
2692
+ "warningCodes": warning_codes,
2693
+ "warnings": warnings,
2135
2694
  "evidenceDir": str(evidence_dir),
2136
2695
  "statePath": str(path),
2137
2696
  "next": "onboarding complete; begin analysis with described field names",
@@ -2213,6 +2772,9 @@ def onboarding_list_command(args: argparse.Namespace) -> dict:
2213
2772
  "completedSteps": completed,
2214
2773
  "totalSteps": len(ordered_steps),
2215
2774
  "steps": {name: steps.get(name, {"status": "pending"}) for name in ordered_steps},
2775
+ "warningCount": steps.get("semanticVerified", {}).get("warningCount", 0),
2776
+ "warningCodes": steps.get("semanticVerified", {}).get("warningCodes", []),
2777
+ "warningEvidence": steps.get("semanticVerified", {}).get("warningEvidence"),
2216
2778
  "next": next_onboarding_action(state),
2217
2779
  })
2218
2780
  except OnboardingError as exc:
@@ -2272,6 +2834,38 @@ def step_completed(state: dict, name: str) -> bool:
2272
2834
  return state.get("steps", {}).get(name, {}).get("status") == "completed"
2273
2835
 
2274
2836
 
2837
+ def reconcile_datasource_checkpoint(
2838
+ state: dict,
2839
+ install_state: dict,
2840
+ data_root: Path,
2841
+ runtime_state: dict,
2842
+ ) -> dict | None:
2843
+ """Reset dependent checkpoints when the live Runtime lost the datasource."""
2844
+ if not step_completed(state, "datasourceConfigured"):
2845
+ return None
2846
+ connection = state["connection"]
2847
+ listed = cli_json(
2848
+ install_state,
2849
+ runtime_state,
2850
+ connection["namespace"],
2851
+ ["datasources", "list"],
2852
+ "datasources list for checkpoint reconciliation",
2853
+ )
2854
+ if matching_datasource(listed, connection) is not None:
2855
+ return None
2856
+ reset_steps = ("datasourceConfigured", "datasourceVerified", "schemaDiscovered")
2857
+ for name in reset_steps:
2858
+ mark_step(state, name, "pending", reason="runtime-datasource-missing")
2859
+ state.get("artifacts", {}).pop("schemaDiscovery", None)
2860
+ write_onboarding_state(data_root, state)
2861
+ return {
2862
+ "recovered": True,
2863
+ "reason": "runtime-datasource-missing",
2864
+ "resetSteps": list(reset_steps),
2865
+ "dataSource": connection["name"],
2866
+ }
2867
+
2868
+
2275
2869
  def bound_project_roots(state: dict) -> list[Path]:
2276
2870
  values = [state.get("projectRoot"), *state.get("workspaceBindings", [])]
2277
2871
  result: list[Path] = []
@@ -2304,9 +2898,10 @@ def bind_completed_workspace(state: dict, data_root: Path, project_root: Path) -
2304
2898
 
2305
2899
 
2306
2900
  def datasource_run_command(args: argparse.Namespace) -> dict:
2307
- _install_root, install_state, data_root, _runtime_state = onboarding_context(args, require_runtime=True)
2901
+ _install_root, install_state, data_root, runtime_state = onboarding_context(args, require_runtime=True)
2308
2902
  project_root = normalized(args.project_root or Path.cwd())
2309
2903
  requested_connection = validate_connection(read_json_object(normalized(args.connection_file), "Connection plan"))
2904
+ resumable_connection = persisted_connection(requested_connection)
2310
2905
  if not requested_connection.get("profile"):
2311
2906
  raise OnboardingError("Composite datasource onboarding requires connection.profile in the approved contract")
2312
2907
  profile = requested_connection["profile"]
@@ -2320,7 +2915,7 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2320
2915
  files: list[str] = []
2321
2916
  existing = read_onboarding_state(data_root, profile, required=False)
2322
2917
  if existing:
2323
- if existing.get("connection") != requested_connection:
2918
+ if persisted_connection(existing.get("connection", {})) != resumable_connection:
2324
2919
  raise OnboardingError("Existing onboarding profile does not match the requested connection plan")
2325
2920
  adopted = bind_completed_workspace(existing, data_root, project_root)
2326
2921
  plan_result = {
@@ -2345,6 +2940,9 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2345
2940
  ))
2346
2941
  save_composite_result(evidence_dir, "01-plan.json", plan_result, files)
2347
2942
  state = read_onboarding_state(data_root, profile)
2943
+ checkpoint_recovery = reconcile_datasource_checkpoint(
2944
+ state, install_state, data_root, runtime_state,
2945
+ )
2348
2946
 
2349
2947
  if step_completed(state, "datasourceConfigured"):
2350
2948
  configured = resumed_phase(profile, "datasourceConfigured")
@@ -2353,7 +2951,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2353
2951
  else:
2354
2952
  configure_dry = datasource_configure_command(argparse.Namespace(
2355
2953
  install_root=args.install_root, data_root=args.data_root, profile=profile, apply=False, replace=False,
2954
+ runtime_password=requested_connection.get("password"),
2356
2955
  ))
2956
+ if checkpoint_recovery:
2957
+ configure_dry["checkpointRecovery"] = checkpoint_recovery
2357
2958
  save_composite_result(evidence_dir, "02-datasource-dry.json", configure_dry, files)
2358
2959
  if not args.approve_configure:
2359
2960
  return {
@@ -2368,7 +2969,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2368
2969
  }
2369
2970
  configured = datasource_configure_command(argparse.Namespace(
2370
2971
  install_root=args.install_root, data_root=args.data_root, profile=profile, apply=True, replace=False,
2972
+ runtime_password=requested_connection.get("password"),
2371
2973
  ))
2974
+ if checkpoint_recovery:
2975
+ configured["checkpointRecovery"] = checkpoint_recovery
2372
2976
  save_composite_result(evidence_dir, "03-datasource-apply.json", configured, files)
2373
2977
  state = read_onboarding_state(data_root, profile)
2374
2978
 
@@ -2633,6 +3237,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2633
3237
  "queryValidated": True,
2634
3238
  "queryExecuted": True,
2635
3239
  "rowCount": verified_step.get("rowCount"),
3240
+ "warningCount": verified_step.get("warningCount", 0),
3241
+ "warningCodes": verified_step.get("warningCodes", []),
3242
+ "warningEvidence": verified_step.get("warningEvidence"),
2636
3243
  "evidenceDir": str(evidence_dir),
2637
3244
  "evidenceFiles": files,
2638
3245
  "productionReady": False,
@@ -2655,6 +3262,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2655
3262
  "phaseStatus": "awaiting-query-execution-approval",
2656
3263
  "queryValidated": True,
2657
3264
  "queryExecuted": False,
3265
+ "warningCount": query_validated.get("warningCount", 0),
3266
+ "warningCodes": query_validated.get("warningCodes", []),
3267
+ "warnings": query_validated.get("warnings", []),
2658
3268
  "evidenceDir": str(evidence_dir),
2659
3269
  "evidenceFiles": files,
2660
3270
  "next": "rerun with all three approval flags after bounded query execution is approved",
@@ -2686,6 +3296,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2686
3296
  "queryValidated": executed["queryValidated"],
2687
3297
  "queryExecuted": executed["queryExecuted"],
2688
3298
  "rowCount": executed["rowCount"],
3299
+ "warningCount": executed.get("warningCount", 0),
3300
+ "warningCodes": executed.get("warningCodes", []),
3301
+ "warnings": executed.get("warnings", []),
2689
3302
  "evidenceDir": str(evidence_dir),
2690
3303
  "evidenceFiles": files,
2691
3304
  "productionReady": False,
@@ -2826,6 +3439,9 @@ def build_parser() -> argparse.ArgumentParser:
2826
3439
  start.add_argument("--namespace")
2827
3440
  start.add_argument("--timeout", type=int)
2828
3441
  start.add_argument("--java")
3442
+ start.add_argument("--progress-file")
3443
+ start.add_argument("--operation-id")
3444
+ start.add_argument("--operation-kind", default="runtime-start")
2829
3445
  start.set_defaults(handler=runtime_start_command)
2830
3446
 
2831
3447
  stop = sub.add_parser("runtime-stop")
@@ -2977,6 +3593,10 @@ def build_parser() -> argparse.ArgumentParser:
2977
3593
 
2978
3594
 
2979
3595
  def main() -> None:
3596
+ for stream in (sys.stdout, sys.stderr):
3597
+ reconfigure = getattr(stream, "reconfigure", None)
3598
+ if callable(reconfigure):
3599
+ reconfigure(encoding="utf-8", errors="replace")
2980
3600
  args = build_parser().parse_args()
2981
3601
  try:
2982
3602
  result = args.handler(args)
@@ -2987,7 +3607,7 @@ def main() -> None:
2987
3607
  except OnboardingError as exc:
2988
3608
  if ACTIVE_PROGRESS is not None:
2989
3609
  ACTIVE_PROGRESS.fail(exc)
2990
- emit({"success": False, "error": {"code": "ONBOARDING_ERROR", "message": str(exc)}, "productionReady": False}, 1)
3610
+ emit({"success": False, "error": {"code": getattr(exc, "code", "ONBOARDING_ERROR"), "message": str(exc)}, "productionReady": False}, 1)
2991
3611
  except Exception as exc:
2992
3612
  if ACTIVE_PROGRESS is not None:
2993
3613
  ACTIVE_PROGRESS.fail(exc)