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