@foggy-projects/deepseek-harness-plugin 0.4.0-beta.9 → 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.
@@ -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}")
@@ -1276,8 +1515,57 @@ def parse_json_output(result: dict, label: str) -> dict:
1276
1515
  raise OnboardingError(f"{label} did not return JSON") from exc
1277
1516
 
1278
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
+
1279
1554
  def runtime_start_command(args: argparse.Namespace) -> dict:
1555
+ global ACTIVE_PROGRESS
1280
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)
1281
1569
  install_root = normalized(args.install_root or default_install_root())
1282
1570
  state = read_install_state(install_root)
1283
1571
  data_root = normalized(args.data_root or state["dataRoot"])
@@ -1296,15 +1584,25 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1296
1584
  base_url = prior.get("runtimeUrl")
1297
1585
  if not isinstance(base_url, str) or not base_url:
1298
1586
  raise OnboardingError("Recorded Runtime does not contain runtimeUrl")
1299
- 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(
1300
1589
  [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready",
1301
- "--timeout-seconds", str(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]),
1590
+ "--timeout-seconds", str(readiness_timeout),
1302
1591
  "--interval-seconds", "1"],
1303
- 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"]),
1304
1601
  )
1305
1602
  wait_payload = parse_json_output(wait_result, "wait-ready")
1306
1603
  if wait_payload.get("success") is not True:
1307
1604
  raise OnboardingError("wait-ready returned success=false for recorded Runtime")
1605
+ progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
1308
1606
  capabilities = parse_json_output(
1309
1607
  command_result(
1310
1608
  [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"],
@@ -1324,6 +1622,8 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1324
1622
  "schemaVersion": capabilities.get("data", {}).get("schemaVersion"),
1325
1623
  "securityMode": capabilities.get("data", {}).get("securityMode"),
1326
1624
  }
1625
+ progress.finish()
1626
+ ACTIVE_PROGRESS = None
1327
1627
  return {
1328
1628
  "success": True,
1329
1629
  **prior,
@@ -1340,16 +1640,15 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1340
1640
  "productionReady": False,
1341
1641
  }
1342
1642
  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
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)
1349
1647
  work_dir = data_root / "runtime"
1350
1648
  work_dir.mkdir(parents=True, exist_ok=True)
1351
1649
  launcher_dir = normalized(state["launcher"]["path"])
1352
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)
1353
1652
  if os.name == "nt":
1354
1653
  shell = shutil.which("pwsh") or shutil.which("powershell")
1355
1654
  if not shell:
@@ -1397,12 +1696,26 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1397
1696
  evidence_dir = data_root / "evidence" / f"runtime-start-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
1398
1697
  evidence_dir.mkdir(parents=True, exist_ok=True)
1399
1698
  atomic_json(evidence_dir / "launch.json", launch)
1699
+ startup_started = time.monotonic()
1400
1700
  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)
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
+ )
1402
1714
  wait_payload = parse_json_output(wait_result, "wait-ready")
1403
1715
  if wait_payload.get("success") is not True:
1404
1716
  raise OnboardingError("wait-ready returned success=false")
1405
1717
  atomic_json(evidence_dir / "wait-ready.json", wait_payload)
1718
+ progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
1406
1719
  capabilities_result = command_result([cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"], timeout=30)
1407
1720
  capabilities = parse_json_output(capabilities_result, "capabilities")
1408
1721
  expected_contract = versions["components"]["launcher"]["runtimeApiContract"]
@@ -1411,12 +1724,21 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1411
1724
  if capabilities.get("data", {}).get("securityMode") != versions["defaults"]["securityMode"]:
1412
1725
  raise OnboardingError("Packaged Launcher did not report the expected dev/test security mode")
1413
1726
  atomic_json(evidence_dir / "capabilities.json", capabilities)
1414
- 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
+ )
1415
1736
  try:
1416
1737
  os.kill(pid, signal.SIGTERM)
1417
1738
  except OSError:
1418
1739
  pass
1419
- 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")
1420
1742
  runtime_state = {
1421
1743
  "schemaVersion": RUNTIME_STATE_SCHEMA,
1422
1744
  "startedAt": now_utc(),
@@ -1435,6 +1757,9 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1435
1757
  },
1436
1758
  }
1437
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
1438
1763
  return {"success": True, **runtime_state, "productionReady": False}
1439
1764
 
1440
1765
 
@@ -1475,6 +1800,108 @@ def cli_json(install_state: dict, runtime_state: dict, namespace: str, command:
1475
1800
  return payload
1476
1801
 
1477
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
+
1478
1905
  CONNECTION_SECRET_KEYS = {
1479
1906
  "jdbcurl", "url", "username", "password", "passwordenv", "passwordref",
1480
1907
  }
@@ -1532,7 +1959,8 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
1532
1959
  if not project_root.is_dir():
1533
1960
  raise OnboardingError(f"Project root not found: {project_root}")
1534
1961
  connection_file = normalized(args.connection_file)
1535
- 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)
1536
1964
  if connection.get("connectionMode") == "opaque-profile":
1537
1965
  require_opaque_profile_cli(install_state)
1538
1966
  existing = read_onboarding_state(data_root, profile, required=False)
@@ -1577,7 +2005,10 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
1577
2005
  "schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
1578
2006
  "profile": profile,
1579
2007
  "statePath": str(path),
1580
- "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
+ },
1581
2012
  "runtimeAvailable": runtime_state is not None,
1582
2013
  "next": "run datasource-configure --apply after reviewing the plan",
1583
2014
  "productionReady": False,
@@ -1608,10 +2039,11 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
1608
2039
  if opaque:
1609
2040
  plan.update({"profileId": connection["opaqueProfileId"], "revision": connection["opaqueRevision"]})
1610
2041
  else:
2042
+ inline_password = getattr(args, "runtime_password", None)
1611
2043
  plan.update({
1612
2044
  "jdbcUrl": connection["jdbcUrl"],
1613
2045
  "username": connection.get("username"),
1614
- "passwordEnv": connection.get("passwordEnv"),
2046
+ "credentialMode": connection.get("credentialMode", "none"),
1615
2047
  })
1616
2048
  if not args.apply:
1617
2049
  return {"success": True, "dryRun": True, "profile": state["profile"], "plan": plan, "next": "rerun with --apply after approval"}
@@ -1623,22 +2055,50 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
1623
2055
  ]
1624
2056
  label = "opaque profile configure"
1625
2057
  else:
2058
+ inline_password = getattr(args, "runtime_password", None)
1626
2059
  password_env = connection.get("passwordEnv")
1627
- 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:
1628
2065
  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
2066
  label = "datasources add"
1635
- if args.replace:
1636
- 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
1637
2071
  already_present = False
1638
2072
  try:
1639
- result = redact_connection_material(
1640
- cli_json(install_state, runtime_state, connection["namespace"], command, label)
1641
- )
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
+ )
1642
2102
  except OnboardingError as exc:
1643
2103
  if "DATASOURCE_ALREADY_EXISTS" not in str(exc):
1644
2104
  raise
@@ -1679,9 +2139,8 @@ def datasource_verify_command(args: argparse.Namespace) -> dict:
1679
2139
  raise OnboardingError("Datasource is not configured; run datasource-configure --apply first")
1680
2140
  connection = state["connection"]
1681
2141
  namespace = connection["namespace"]
1682
- tested = redact_connection_material(cli_json(
1683
- install_state, runtime_state, namespace,
1684
- ["datasources", "test", connection["name"]], "datasources test",
2142
+ tested = redact_connection_material(datasource_test_with_retry(
2143
+ install_state, runtime_state, namespace, connection["name"],
1685
2144
  ))
1686
2145
  if not args.bind:
1687
2146
  mark_step(state, "datasourceVerified", "waiting-for-binding", connectionTested=True)
@@ -2054,6 +2513,52 @@ def nested_data_objects(payload: dict) -> list[dict]:
2054
2513
  return objects
2055
2514
 
2056
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
+
2057
2562
  def response_field_count(payload: dict) -> int | None:
2058
2563
  for item in nested_data_objects(payload):
2059
2564
  fields = item.get("fields")
@@ -2104,8 +2609,18 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2104
2609
  ["query", "validate", query_model, "--payload", str(payload_path)], "query validate", timeout=90,
2105
2610
  )
2106
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)
2107
2614
  if not args.execute:
2108
- 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
+ )
2109
2624
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
2110
2625
  path = write_onboarding_state(data_root, state)
2111
2626
  return {
@@ -2116,6 +2631,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2116
2631
  "queryModel": query_model,
2117
2632
  "queryValidated": True,
2118
2633
  "queryExecuted": False,
2634
+ "warningCount": len(validation_warnings),
2635
+ "warningCodes": validation_warning_codes,
2636
+ "warnings": validation_warnings,
2119
2637
  "evidenceDir": str(evidence_dir),
2120
2638
  "statePath": str(path),
2121
2639
  "next": "rerun semantic-verify with --execute after approving the bounded business-data query",
@@ -2126,6 +2644,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2126
2644
  ["query", "execute", query_model, "--payload", str(payload_path)], "query execute", timeout=120,
2127
2645
  )
2128
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)
2129
2650
  row_count = response_row_count(executed)
2130
2651
  mark_step(
2131
2652
  state,
@@ -2137,6 +2658,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2137
2658
  rowCount=row_count,
2138
2659
  queryPayloadDigest=sha256(payload_path),
2139
2660
  projectRoot=str(project_root),
2661
+ warningCount=len(warnings),
2662
+ warningCodes=warning_codes,
2663
+ warningEvidence=str(evidence_dir),
2140
2664
  )
2141
2665
  workspace_verifications = state.setdefault("workspaceVerifications", [])
2142
2666
  workspace_verifications[:] = [
@@ -2148,6 +2672,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2148
2672
  "queryModel": query_model,
2149
2673
  "queryPayloadDigest": sha256(payload_path),
2150
2674
  "rowCount": row_count,
2675
+ "warningCount": len(warnings),
2676
+ "warningCodes": warning_codes,
2677
+ "warningEvidence": str(evidence_dir),
2151
2678
  "verifiedAt": now_utc(),
2152
2679
  })
2153
2680
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
@@ -2161,6 +2688,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
2161
2688
  "queryValidated": True,
2162
2689
  "queryExecuted": True,
2163
2690
  "rowCount": row_count,
2691
+ "warningCount": len(warnings),
2692
+ "warningCodes": warning_codes,
2693
+ "warnings": warnings,
2164
2694
  "evidenceDir": str(evidence_dir),
2165
2695
  "statePath": str(path),
2166
2696
  "next": "onboarding complete; begin analysis with described field names",
@@ -2242,6 +2772,9 @@ def onboarding_list_command(args: argparse.Namespace) -> dict:
2242
2772
  "completedSteps": completed,
2243
2773
  "totalSteps": len(ordered_steps),
2244
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"),
2245
2778
  "next": next_onboarding_action(state),
2246
2779
  })
2247
2780
  except OnboardingError as exc:
@@ -2301,6 +2834,38 @@ def step_completed(state: dict, name: str) -> bool:
2301
2834
  return state.get("steps", {}).get(name, {}).get("status") == "completed"
2302
2835
 
2303
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
+
2304
2869
  def bound_project_roots(state: dict) -> list[Path]:
2305
2870
  values = [state.get("projectRoot"), *state.get("workspaceBindings", [])]
2306
2871
  result: list[Path] = []
@@ -2333,9 +2898,10 @@ def bind_completed_workspace(state: dict, data_root: Path, project_root: Path) -
2333
2898
 
2334
2899
 
2335
2900
  def datasource_run_command(args: argparse.Namespace) -> dict:
2336
- _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)
2337
2902
  project_root = normalized(args.project_root or Path.cwd())
2338
2903
  requested_connection = validate_connection(read_json_object(normalized(args.connection_file), "Connection plan"))
2904
+ resumable_connection = persisted_connection(requested_connection)
2339
2905
  if not requested_connection.get("profile"):
2340
2906
  raise OnboardingError("Composite datasource onboarding requires connection.profile in the approved contract")
2341
2907
  profile = requested_connection["profile"]
@@ -2349,7 +2915,7 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2349
2915
  files: list[str] = []
2350
2916
  existing = read_onboarding_state(data_root, profile, required=False)
2351
2917
  if existing:
2352
- if existing.get("connection") != requested_connection:
2918
+ if persisted_connection(existing.get("connection", {})) != resumable_connection:
2353
2919
  raise OnboardingError("Existing onboarding profile does not match the requested connection plan")
2354
2920
  adopted = bind_completed_workspace(existing, data_root, project_root)
2355
2921
  plan_result = {
@@ -2374,6 +2940,9 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2374
2940
  ))
2375
2941
  save_composite_result(evidence_dir, "01-plan.json", plan_result, files)
2376
2942
  state = read_onboarding_state(data_root, profile)
2943
+ checkpoint_recovery = reconcile_datasource_checkpoint(
2944
+ state, install_state, data_root, runtime_state,
2945
+ )
2377
2946
 
2378
2947
  if step_completed(state, "datasourceConfigured"):
2379
2948
  configured = resumed_phase(profile, "datasourceConfigured")
@@ -2382,7 +2951,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2382
2951
  else:
2383
2952
  configure_dry = datasource_configure_command(argparse.Namespace(
2384
2953
  install_root=args.install_root, data_root=args.data_root, profile=profile, apply=False, replace=False,
2954
+ runtime_password=requested_connection.get("password"),
2385
2955
  ))
2956
+ if checkpoint_recovery:
2957
+ configure_dry["checkpointRecovery"] = checkpoint_recovery
2386
2958
  save_composite_result(evidence_dir, "02-datasource-dry.json", configure_dry, files)
2387
2959
  if not args.approve_configure:
2388
2960
  return {
@@ -2397,7 +2969,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2397
2969
  }
2398
2970
  configured = datasource_configure_command(argparse.Namespace(
2399
2971
  install_root=args.install_root, data_root=args.data_root, profile=profile, apply=True, replace=False,
2972
+ runtime_password=requested_connection.get("password"),
2400
2973
  ))
2974
+ if checkpoint_recovery:
2975
+ configured["checkpointRecovery"] = checkpoint_recovery
2401
2976
  save_composite_result(evidence_dir, "03-datasource-apply.json", configured, files)
2402
2977
  state = read_onboarding_state(data_root, profile)
2403
2978
 
@@ -2662,6 +3237,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2662
3237
  "queryValidated": True,
2663
3238
  "queryExecuted": True,
2664
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"),
2665
3243
  "evidenceDir": str(evidence_dir),
2666
3244
  "evidenceFiles": files,
2667
3245
  "productionReady": False,
@@ -2684,6 +3262,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2684
3262
  "phaseStatus": "awaiting-query-execution-approval",
2685
3263
  "queryValidated": True,
2686
3264
  "queryExecuted": False,
3265
+ "warningCount": query_validated.get("warningCount", 0),
3266
+ "warningCodes": query_validated.get("warningCodes", []),
3267
+ "warnings": query_validated.get("warnings", []),
2687
3268
  "evidenceDir": str(evidence_dir),
2688
3269
  "evidenceFiles": files,
2689
3270
  "next": "rerun with all three approval flags after bounded query execution is approved",
@@ -2715,6 +3296,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2715
3296
  "queryValidated": executed["queryValidated"],
2716
3297
  "queryExecuted": executed["queryExecuted"],
2717
3298
  "rowCount": executed["rowCount"],
3299
+ "warningCount": executed.get("warningCount", 0),
3300
+ "warningCodes": executed.get("warningCodes", []),
3301
+ "warnings": executed.get("warnings", []),
2718
3302
  "evidenceDir": str(evidence_dir),
2719
3303
  "evidenceFiles": files,
2720
3304
  "productionReady": False,
@@ -2855,6 +3439,9 @@ def build_parser() -> argparse.ArgumentParser:
2855
3439
  start.add_argument("--namespace")
2856
3440
  start.add_argument("--timeout", type=int)
2857
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")
2858
3445
  start.set_defaults(handler=runtime_start_command)
2859
3446
 
2860
3447
  stop = sub.add_parser("runtime-stop")
@@ -3006,6 +3593,10 @@ def build_parser() -> argparse.ArgumentParser:
3006
3593
 
3007
3594
 
3008
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")
3009
3600
  args = build_parser().parse_args()
3010
3601
  try:
3011
3602
  result = args.handler(args)
@@ -3016,7 +3607,7 @@ def main() -> None:
3016
3607
  except OnboardingError as exc:
3017
3608
  if ACTIVE_PROGRESS is not None:
3018
3609
  ACTIVE_PROGRESS.fail(exc)
3019
- 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)
3020
3611
  except Exception as exc:
3021
3612
  if ACTIVE_PROGRESS is not None:
3022
3613
  ACTIVE_PROGRESS.fail(exc)