@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.
- package/README.md +55 -20
- package/docs/PUBLIC-BETA-READINESS.md +71 -15
- package/docs/RELEASE-CANDIDATE-0.4.0-RC.1.md +61 -0
- package/docs/WINDOWS-BETA-ACCEPTANCE.md +56 -21
- package/experience/linux/prepare.sh +2 -2
- package/lib/client.js +239 -26
- package/lib/diagnostics.js +86 -0
- package/lib/index.js +95 -12
- package/lib/remote-descriptor.js +15 -2
- package/lib/runtime-settings.js +89 -0
- package/package.json +11 -10
- package/skills/foggy-deepseek-onboarding/SKILL.md +126 -127
- package/skills/foggy-deepseek-onboarding/assets/connection.schema.json +6 -8
- package/skills/foggy-deepseek-onboarding/assets/datasource.example.json +3 -2
- package/skills/foggy-deepseek-onboarding/assets/env.example +4 -4
- package/skills/foggy-deepseek-onboarding/assets/onboarding-state.schema.json +30 -1
- package/skills/foggy-deepseek-onboarding/assets/versions.json +18 -18
- package/skills/foggy-deepseek-onboarding/references/onboarding-workflow.md +134 -122
- package/skills/foggy-deepseek-onboarding/scripts/invoke-onboarding.ps1 +2 -0
- package/skills/foggy-deepseek-onboarding/scripts/onboarding.py +636 -45
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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 =
|
|
159
|
+
self.phase = self.completion_phase
|
|
120
160
|
self.step_index = self.total_steps
|
|
121
161
|
self.fraction = 0.0
|
|
122
|
-
self.
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
1590
|
+
"--timeout-seconds", str(readiness_timeout),
|
|
1302
1591
|
"--interval-seconds", "1"],
|
|
1303
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": {
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
1640
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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") !=
|
|
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)
|