@foggy-projects/deepseek-harness-plugin 0.4.0-beta.9 → 0.4.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -20
- package/docs/PUBLIC-BETA-READINESS.md +71 -15
- package/docs/RELEASE-CANDIDATE-0.4.0-RC.2.md +38 -0
- package/docs/WINDOWS-BETA-ACCEPTANCE.md +57 -22
- package/experience/linux/prepare.sh +2 -2
- package/lib/client.js +249 -27
- package/lib/diagnostics.js +86 -0
- package/lib/index.js +113 -15
- package/lib/remote-descriptor.js +16 -2
- package/lib/runtime-settings.js +89 -0
- package/lib/skill-provider.js +13 -8
- 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 +54 -29
- 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 +689 -81
|
@@ -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}")
|
|
@@ -929,22 +1168,26 @@ def safe_extract(zip_path: Path, destination: Path) -> None:
|
|
|
929
1168
|
archive.extractall(destination)
|
|
930
1169
|
|
|
931
1170
|
|
|
932
|
-
def
|
|
1171
|
+
def install_managed_skill(
|
|
933
1172
|
zip_path: Path,
|
|
934
1173
|
install_root: Path,
|
|
935
1174
|
version: str,
|
|
936
1175
|
expected_hash: str,
|
|
937
1176
|
package_version: str,
|
|
938
1177
|
replace: bool,
|
|
1178
|
+
*,
|
|
1179
|
+
skill_name: str,
|
|
1180
|
+
kind: str,
|
|
1181
|
+
display_name: str,
|
|
939
1182
|
) -> dict:
|
|
940
1183
|
skills_root = install_root / "skills"
|
|
941
|
-
destination = skills_root /
|
|
1184
|
+
destination = skills_root / skill_name
|
|
942
1185
|
with tempfile.TemporaryDirectory(prefix="foggy-skill-") as temporary:
|
|
943
1186
|
extract_root = Path(temporary)
|
|
944
1187
|
safe_extract(zip_path, extract_root)
|
|
945
1188
|
candidates = list(extract_root.rglob("SKILL.md"))
|
|
946
1189
|
if len(candidates) != 1:
|
|
947
|
-
raise OnboardingError(f"Expected exactly one SKILL.md in
|
|
1190
|
+
raise OnboardingError(f"Expected exactly one SKILL.md in {display_name} archive, found {len(candidates)}")
|
|
948
1191
|
source = candidates[0].parent
|
|
949
1192
|
source_digest = skill_tree_digest(source)
|
|
950
1193
|
if destination.exists():
|
|
@@ -960,7 +1203,7 @@ def install_analysis_skill(
|
|
|
960
1203
|
if marker_matches:
|
|
961
1204
|
marker = write_skill_marker(
|
|
962
1205
|
destination,
|
|
963
|
-
kind=
|
|
1206
|
+
kind=kind,
|
|
964
1207
|
package_version=package_version,
|
|
965
1208
|
component_version=version,
|
|
966
1209
|
source_digest=source_digest,
|
|
@@ -968,13 +1211,13 @@ def install_analysis_skill(
|
|
|
968
1211
|
)
|
|
969
1212
|
return {"path": str(destination), "version": version, "digest": marker["installedDigest"], "managed": True, "action": "kept-matching"}
|
|
970
1213
|
if not replace:
|
|
971
|
-
raise OnboardingError(f"
|
|
1214
|
+
raise OnboardingError(f"{display_name} is missing, modified, or outdated at {destination}; use the plugin Repair action to back it up and restore it")
|
|
972
1215
|
backup_skill(destination, install_root / "skill-backups")
|
|
973
1216
|
skills_root.mkdir(parents=True, exist_ok=True)
|
|
974
1217
|
shutil.copytree(source, destination)
|
|
975
1218
|
marker = write_skill_marker(
|
|
976
1219
|
destination,
|
|
977
|
-
kind=
|
|
1220
|
+
kind=kind,
|
|
978
1221
|
package_version=package_version,
|
|
979
1222
|
component_version=version,
|
|
980
1223
|
source_digest=source_digest,
|
|
@@ -1025,7 +1268,7 @@ def install_command(args: argparse.Namespace) -> dict:
|
|
|
1025
1268
|
"workspaceMode": "dsh-session-cwd",
|
|
1026
1269
|
"versions": {name: value.get("version") for name, value in components.items()},
|
|
1027
1270
|
"repairComponent": repair_component,
|
|
1028
|
-
"operations": ["verify private Python", "install isolated CLI", "verify Launcher assets", "install global analysis
|
|
1271
|
+
"operations": ["verify private Python", "install isolated CLI", "verify Launcher assets", "install global analysis/query Skills", "write install state"],
|
|
1029
1272
|
"productionReady": False,
|
|
1030
1273
|
}
|
|
1031
1274
|
if args.dry_run:
|
|
@@ -1125,32 +1368,41 @@ def install_command(args: argparse.Namespace) -> dict:
|
|
|
1125
1368
|
(launcher_dir / "start-foggy-runtime.sh").chmod(0o755)
|
|
1126
1369
|
progress.update("launcher", 3, "Launcher ready", fraction=1.0)
|
|
1127
1370
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
fraction=index / len(analysis_assets), current_file=asset["file"],
|
|
1133
|
-
completed_files=index, total_files=len(analysis_assets),
|
|
1134
|
-
)
|
|
1135
|
-
verified.append(materialize(
|
|
1136
|
-
asset, downloads / "skill" / asset["file"], cache_dirs,
|
|
1137
|
-
progress=progress, progress_phase="analysis-skill", progress_step=4,
|
|
1138
|
-
progress_index=index, progress_total=len(analysis_assets),
|
|
1139
|
-
progress_message="Downloading and verifying analysis Skill",
|
|
1140
|
-
replace_corrupt=repair_component == "analysis-skill",
|
|
1141
|
-
))
|
|
1142
|
-
progress.update(
|
|
1143
|
-
"analysis-skill", 4, "Downloading and verifying analysis Skill",
|
|
1144
|
-
fraction=(index + 1) / len(analysis_assets), current_file=asset["file"],
|
|
1145
|
-
completed_files=index + 1, total_files=len(analysis_assets),
|
|
1146
|
-
)
|
|
1147
|
-
zip_asset = next(item for item in analysis_assets if item["role"] == "zip")
|
|
1148
|
-
progress.update("analysis-skill", 4, "Installing analysis Skill", fraction=0.9, current_file=zip_asset["file"])
|
|
1149
|
-
analysis_skill = install_analysis_skill(
|
|
1150
|
-
downloads / "skill" / zip_asset["file"], install_root, components["analysisSkill"]["version"],
|
|
1151
|
-
zip_asset["sha256"], versions["packageVersion"], args.replace_skill or repair_component == "analysis-skill",
|
|
1371
|
+
installed_skills = {}
|
|
1372
|
+
skill_specs = (
|
|
1373
|
+
("analysisSkill", "analysis", "foggy-ai-analysis", "Analysis Skill", "analysis-skill"),
|
|
1374
|
+
("semanticQuerySkill", "semantic-query", "foggy-semantic-query", "Semantic Query Skill", "semantic-query-skill"),
|
|
1152
1375
|
)
|
|
1153
|
-
|
|
1376
|
+
for component_key, state_key, skill_name, display_name, repair_key in skill_specs:
|
|
1377
|
+
skill_assets = components[component_key]["assets"]
|
|
1378
|
+
for index, asset in enumerate(skill_assets):
|
|
1379
|
+
progress.update(
|
|
1380
|
+
repair_key, 4, f"Downloading and verifying {display_name}",
|
|
1381
|
+
fraction=index / len(skill_assets), current_file=asset["file"],
|
|
1382
|
+
completed_files=index, total_files=len(skill_assets),
|
|
1383
|
+
)
|
|
1384
|
+
verified.append(materialize(
|
|
1385
|
+
asset, downloads / "skill" / asset["file"], cache_dirs,
|
|
1386
|
+
progress=progress, progress_phase=repair_key, progress_step=4,
|
|
1387
|
+
progress_index=index, progress_total=len(skill_assets),
|
|
1388
|
+
progress_message=f"Downloading and verifying {display_name}",
|
|
1389
|
+
replace_corrupt=repair_component == repair_key,
|
|
1390
|
+
))
|
|
1391
|
+
progress.update(
|
|
1392
|
+
repair_key, 4, f"Downloading and verifying {display_name}",
|
|
1393
|
+
fraction=(index + 1) / len(skill_assets), current_file=asset["file"],
|
|
1394
|
+
completed_files=index + 1, total_files=len(skill_assets),
|
|
1395
|
+
)
|
|
1396
|
+
zip_asset = next(item for item in skill_assets if item["role"] == "zip")
|
|
1397
|
+
progress.update(repair_key, 4, f"Installing {display_name}", fraction=0.9, current_file=zip_asset["file"])
|
|
1398
|
+
installed_skills[state_key] = install_managed_skill(
|
|
1399
|
+
downloads / "skill" / zip_asset["file"], install_root, components[component_key]["version"],
|
|
1400
|
+
zip_asset["sha256"], versions["packageVersion"], args.replace_skill or repair_component == repair_key,
|
|
1401
|
+
skill_name=skill_name, kind=state_key, display_name=display_name,
|
|
1402
|
+
)
|
|
1403
|
+
progress.update(repair_key, 4, f"{display_name} ready", fraction=1.0)
|
|
1404
|
+
analysis_skill = installed_skills["analysis"]
|
|
1405
|
+
semantic_query_skill = installed_skills["semantic-query"]
|
|
1154
1406
|
progress.update("workspace-skills", 5, "Registering native DSH Skills", fraction=0.1)
|
|
1155
1407
|
onboarding_skill = {
|
|
1156
1408
|
"path": str(skill_root()),
|
|
@@ -1175,7 +1427,7 @@ def install_command(args: argparse.Namespace) -> dict:
|
|
|
1175
1427
|
},
|
|
1176
1428
|
"cli": {"version": cli_component["version"], "command": str(cli_command), "mode": cli_mode},
|
|
1177
1429
|
"launcher": {"version": components["launcher"]["version"], "path": str(launcher_dir)},
|
|
1178
|
-
"skills": {"onboarding": onboarding_skill, "analysis": analysis_skill},
|
|
1430
|
+
"skills": {"onboarding": onboarding_skill, "analysis": analysis_skill, "semanticQuery": semantic_query_skill},
|
|
1179
1431
|
"verifiedAssets": verified,
|
|
1180
1432
|
"securityMode": versions["defaults"]["securityMode"],
|
|
1181
1433
|
"productionReady": False,
|
|
@@ -1194,6 +1446,7 @@ def install_command(args: argparse.Namespace) -> dict:
|
|
|
1194
1446
|
"cliVersion": cli_component["version"],
|
|
1195
1447
|
"launcherVersion": components["launcher"]["version"],
|
|
1196
1448
|
"analysisSkill": analysis_skill,
|
|
1449
|
+
"semanticQuerySkill": semantic_query_skill,
|
|
1197
1450
|
"onboardingSkill": onboarding_skill,
|
|
1198
1451
|
"java": java_probe(),
|
|
1199
1452
|
"next": "run doctor, then runtime-start",
|
|
@@ -1276,8 +1529,57 @@ def parse_json_output(result: dict, label: str) -> dict:
|
|
|
1276
1529
|
raise OnboardingError(f"{label} did not return JSON") from exc
|
|
1277
1530
|
|
|
1278
1531
|
|
|
1532
|
+
def write_runtime_start_failure(
|
|
1533
|
+
data_root: Path,
|
|
1534
|
+
evidence_dir: Path,
|
|
1535
|
+
launch: dict,
|
|
1536
|
+
phase: str,
|
|
1537
|
+
error: object,
|
|
1538
|
+
elapsed_ms: int,
|
|
1539
|
+
) -> dict:
|
|
1540
|
+
logs = []
|
|
1541
|
+
for name in ("stdoutLog", "stderrLog"):
|
|
1542
|
+
value = launch.get(name)
|
|
1543
|
+
path = normalized(value) if isinstance(value, str) and value else None
|
|
1544
|
+
item = {"name": name, "path": str(path) if path else None, "exists": bool(path and path.is_file())}
|
|
1545
|
+
if path and path.is_file():
|
|
1546
|
+
stat = path.stat()
|
|
1547
|
+
item.update({"sizeBytes": stat.st_size, "modifiedAt": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat()})
|
|
1548
|
+
logs.append(item)
|
|
1549
|
+
pid = int(launch.get("pid", 0))
|
|
1550
|
+
failure = {
|
|
1551
|
+
"schemaVersion": "foggy-deepseek-runtime-start-failure/v1",
|
|
1552
|
+
"failedAt": now_utc(),
|
|
1553
|
+
"phase": phase,
|
|
1554
|
+
"message": str(error),
|
|
1555
|
+
"elapsedMs": elapsed_ms,
|
|
1556
|
+
"pid": pid or None,
|
|
1557
|
+
"processRunningBeforeCleanup": process_info(pid)["running"] if pid else False,
|
|
1558
|
+
"runtimeUrl": launch.get("runtimeUrl"),
|
|
1559
|
+
"workDir": launch.get("workDir"),
|
|
1560
|
+
"evidenceDir": str(evidence_dir),
|
|
1561
|
+
"logs": logs,
|
|
1562
|
+
}
|
|
1563
|
+
atomic_json(evidence_dir / "failure.json", failure)
|
|
1564
|
+
atomic_json(data_root / "last-runtime-start-failure.json", failure)
|
|
1565
|
+
return failure
|
|
1566
|
+
|
|
1567
|
+
|
|
1279
1568
|
def runtime_start_command(args: argparse.Namespace) -> dict:
|
|
1569
|
+
global ACTIVE_PROGRESS
|
|
1280
1570
|
versions = load_versions()
|
|
1571
|
+
readiness_timeout = args.timeout or int(versions["defaults"]["readinessTimeoutSeconds"])
|
|
1572
|
+
progress = ProgressReporter(
|
|
1573
|
+
getattr(args, "progress_file", None),
|
|
1574
|
+
getattr(args, "operation_id", None),
|
|
1575
|
+
getattr(args, "operation_kind", "runtime-start"),
|
|
1576
|
+
total_steps=6,
|
|
1577
|
+
completion_phase="runtime-complete",
|
|
1578
|
+
completion_message="Foggy Runtime is ready",
|
|
1579
|
+
failure_message="Foggy Runtime startup failed",
|
|
1580
|
+
)
|
|
1581
|
+
ACTIVE_PROGRESS = progress
|
|
1582
|
+
progress.update("runtime-preflight", 0, "Checking Runtime state and prerequisites", percent=3)
|
|
1281
1583
|
install_root = normalized(args.install_root or default_install_root())
|
|
1282
1584
|
state = read_install_state(install_root)
|
|
1283
1585
|
data_root = normalized(args.data_root or state["dataRoot"])
|
|
@@ -1296,15 +1598,25 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
|
|
|
1296
1598
|
base_url = prior.get("runtimeUrl")
|
|
1297
1599
|
if not isinstance(base_url, str) or not base_url:
|
|
1298
1600
|
raise OnboardingError("Recorded Runtime does not contain runtimeUrl")
|
|
1299
|
-
|
|
1601
|
+
progress.update("runtime-readiness", 3, "Revalidating the recorded Runtime", percent=30, elapsed_seconds=0, timeout_seconds=readiness_timeout)
|
|
1602
|
+
wait_result = command_result_with_progress(
|
|
1300
1603
|
[cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready",
|
|
1301
|
-
"--timeout-seconds", str(
|
|
1604
|
+
"--timeout-seconds", str(readiness_timeout),
|
|
1302
1605
|
"--interval-seconds", "1"],
|
|
1303
|
-
|
|
1606
|
+
readiness_timeout + 30,
|
|
1607
|
+
progress,
|
|
1608
|
+
phase="runtime-readiness",
|
|
1609
|
+
step_index=3,
|
|
1610
|
+
message="Revalidating the recorded Runtime",
|
|
1611
|
+
readiness_timeout=readiness_timeout,
|
|
1612
|
+
start_percent=30,
|
|
1613
|
+
end_percent=85,
|
|
1614
|
+
watched_pid=int(prior["pid"]),
|
|
1304
1615
|
)
|
|
1305
1616
|
wait_payload = parse_json_output(wait_result, "wait-ready")
|
|
1306
1617
|
if wait_payload.get("success") is not True:
|
|
1307
1618
|
raise OnboardingError("wait-ready returned success=false for recorded Runtime")
|
|
1619
|
+
progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
|
|
1308
1620
|
capabilities = parse_json_output(
|
|
1309
1621
|
command_result(
|
|
1310
1622
|
[cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"],
|
|
@@ -1324,6 +1636,8 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
|
|
|
1324
1636
|
"schemaVersion": capabilities.get("data", {}).get("schemaVersion"),
|
|
1325
1637
|
"securityMode": capabilities.get("data", {}).get("securityMode"),
|
|
1326
1638
|
}
|
|
1639
|
+
progress.finish()
|
|
1640
|
+
ACTIVE_PROGRESS = None
|
|
1327
1641
|
return {
|
|
1328
1642
|
"success": True,
|
|
1329
1643
|
**prior,
|
|
@@ -1340,16 +1654,15 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
|
|
|
1340
1654
|
"productionReady": False,
|
|
1341
1655
|
}
|
|
1342
1656
|
existing.unlink()
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
except OSError as exc:
|
|
1348
|
-
raise OnboardingError(f"Port {port} is not available") from exc
|
|
1657
|
+
progress.update("runtime-preflight", 1, "Checking port and Runtime workspace", percent=10)
|
|
1658
|
+
configured_runtime = read_runtime_settings(data_root, versions["defaults"]["port"])
|
|
1659
|
+
port = validate_runtime_port(args.port) if args.port is not None else configured_runtime["port"]
|
|
1660
|
+
assert_runtime_port_available(data_root, port)
|
|
1349
1661
|
work_dir = data_root / "runtime"
|
|
1350
1662
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|
1351
1663
|
launcher_dir = normalized(state["launcher"]["path"])
|
|
1352
1664
|
java_exe = args.java or os.environ.get("JAVA_EXE", "java")
|
|
1665
|
+
progress.update("runtime-launcher", 2, "Starting the Java Runtime through Launcher", percent=20)
|
|
1353
1666
|
if os.name == "nt":
|
|
1354
1667
|
shell = shutil.which("pwsh") or shutil.which("powershell")
|
|
1355
1668
|
if not shell:
|
|
@@ -1397,12 +1710,26 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
|
|
|
1397
1710
|
evidence_dir = data_root / "evidence" / f"runtime-start-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
1398
1711
|
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
1399
1712
|
atomic_json(evidence_dir / "launch.json", launch)
|
|
1713
|
+
startup_started = time.monotonic()
|
|
1400
1714
|
try:
|
|
1401
|
-
|
|
1715
|
+
progress.update("runtime-readiness", 3, "Waiting for Java Runtime readiness", percent=30, elapsed_seconds=0, timeout_seconds=readiness_timeout)
|
|
1716
|
+
wait_result = command_result_with_progress(
|
|
1717
|
+
[cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready", "--timeout-seconds", str(readiness_timeout), "--interval-seconds", "1"],
|
|
1718
|
+
readiness_timeout + 30,
|
|
1719
|
+
progress,
|
|
1720
|
+
phase="runtime-readiness",
|
|
1721
|
+
step_index=3,
|
|
1722
|
+
message="Waiting for Java Runtime readiness",
|
|
1723
|
+
readiness_timeout=readiness_timeout,
|
|
1724
|
+
start_percent=30,
|
|
1725
|
+
end_percent=85,
|
|
1726
|
+
watched_pid=pid,
|
|
1727
|
+
)
|
|
1402
1728
|
wait_payload = parse_json_output(wait_result, "wait-ready")
|
|
1403
1729
|
if wait_payload.get("success") is not True:
|
|
1404
1730
|
raise OnboardingError("wait-ready returned success=false")
|
|
1405
1731
|
atomic_json(evidence_dir / "wait-ready.json", wait_payload)
|
|
1732
|
+
progress.update("runtime-capabilities", 4, "Verifying Runtime API capabilities", percent=90)
|
|
1406
1733
|
capabilities_result = command_result([cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"], timeout=30)
|
|
1407
1734
|
capabilities = parse_json_output(capabilities_result, "capabilities")
|
|
1408
1735
|
expected_contract = versions["components"]["launcher"]["runtimeApiContract"]
|
|
@@ -1411,12 +1738,21 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
|
|
|
1411
1738
|
if capabilities.get("data", {}).get("securityMode") != versions["defaults"]["securityMode"]:
|
|
1412
1739
|
raise OnboardingError("Packaged Launcher did not report the expected dev/test security mode")
|
|
1413
1740
|
atomic_json(evidence_dir / "capabilities.json", capabilities)
|
|
1414
|
-
except Exception:
|
|
1741
|
+
except Exception as exc:
|
|
1742
|
+
failure = write_runtime_start_failure(
|
|
1743
|
+
data_root,
|
|
1744
|
+
evidence_dir,
|
|
1745
|
+
launch,
|
|
1746
|
+
progress.phase,
|
|
1747
|
+
exc,
|
|
1748
|
+
round((time.monotonic() - startup_started) * 1000),
|
|
1749
|
+
)
|
|
1415
1750
|
try:
|
|
1416
1751
|
os.kill(pid, signal.SIGTERM)
|
|
1417
1752
|
except OSError:
|
|
1418
1753
|
pass
|
|
1419
|
-
raise
|
|
1754
|
+
raise OnboardingError(f"{exc}; startup diagnostics: {failure['evidenceDir']}") from exc
|
|
1755
|
+
progress.update("runtime-state", 5, "Saving verified Runtime state", percent=96, current_file="runtime-state.json")
|
|
1420
1756
|
runtime_state = {
|
|
1421
1757
|
"schemaVersion": RUNTIME_STATE_SCHEMA,
|
|
1422
1758
|
"startedAt": now_utc(),
|
|
@@ -1435,6 +1771,9 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
|
|
|
1435
1771
|
},
|
|
1436
1772
|
}
|
|
1437
1773
|
atomic_json(data_root / "runtime-state.json", runtime_state)
|
|
1774
|
+
(data_root / "last-runtime-start-failure.json").unlink(missing_ok=True)
|
|
1775
|
+
progress.finish()
|
|
1776
|
+
ACTIVE_PROGRESS = None
|
|
1438
1777
|
return {"success": True, **runtime_state, "productionReady": False}
|
|
1439
1778
|
|
|
1440
1779
|
|
|
@@ -1475,6 +1814,108 @@ def cli_json(install_state: dict, runtime_state: dict, namespace: str, command:
|
|
|
1475
1814
|
return payload
|
|
1476
1815
|
|
|
1477
1816
|
|
|
1817
|
+
TRANSIENT_DATASOURCE_TEST_MARKERS = (
|
|
1818
|
+
"connection is not available",
|
|
1819
|
+
"connection pool",
|
|
1820
|
+
"request timed out",
|
|
1821
|
+
"timeout waiting for connection",
|
|
1822
|
+
)
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def datasource_test_with_retry(
|
|
1826
|
+
install_state: dict,
|
|
1827
|
+
runtime_state: dict,
|
|
1828
|
+
namespace: str,
|
|
1829
|
+
datasource_name: str,
|
|
1830
|
+
*,
|
|
1831
|
+
attempts: int = 3,
|
|
1832
|
+
initial_delay_seconds: float = 1.0,
|
|
1833
|
+
) -> dict:
|
|
1834
|
+
"""Retry only transient pool/readiness failures; authentication and configuration failures fail fast."""
|
|
1835
|
+
for attempt in range(1, attempts + 1):
|
|
1836
|
+
try:
|
|
1837
|
+
result = cli_json(
|
|
1838
|
+
install_state,
|
|
1839
|
+
runtime_state,
|
|
1840
|
+
namespace,
|
|
1841
|
+
["datasources", "test", datasource_name],
|
|
1842
|
+
"datasources test",
|
|
1843
|
+
)
|
|
1844
|
+
if attempt > 1:
|
|
1845
|
+
result = {
|
|
1846
|
+
**result,
|
|
1847
|
+
"onboardingRetry": {
|
|
1848
|
+
"attempts": attempt,
|
|
1849
|
+
"reason": "transient-datasource-readiness",
|
|
1850
|
+
},
|
|
1851
|
+
}
|
|
1852
|
+
return result
|
|
1853
|
+
except OnboardingError as exc:
|
|
1854
|
+
transient = any(marker in str(exc).lower() for marker in TRANSIENT_DATASOURCE_TEST_MARKERS)
|
|
1855
|
+
if not transient or attempt == attempts:
|
|
1856
|
+
raise
|
|
1857
|
+
time.sleep(initial_delay_seconds * attempt)
|
|
1858
|
+
raise AssertionError("unreachable")
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def runtime_api_json(
|
|
1862
|
+
runtime_state: dict,
|
|
1863
|
+
namespace: str,
|
|
1864
|
+
method: str,
|
|
1865
|
+
path: str,
|
|
1866
|
+
body: dict | None,
|
|
1867
|
+
label: str,
|
|
1868
|
+
timeout: int = 60,
|
|
1869
|
+
) -> dict:
|
|
1870
|
+
"""Call the public Runtime API for development inputs not yet exposed by the pinned CLI."""
|
|
1871
|
+
base_url = str(runtime_state["runtimeUrl"]).rstrip("/")
|
|
1872
|
+
request_body = json.dumps(body).encode("utf-8") if body is not None else None
|
|
1873
|
+
headers = {
|
|
1874
|
+
"Accept": "application/json",
|
|
1875
|
+
"Content-Type": "application/json",
|
|
1876
|
+
"X-NS": namespace,
|
|
1877
|
+
}
|
|
1878
|
+
auth_code = os.environ.get("FOGGY_RUNTIME_API_AUTH_CODE")
|
|
1879
|
+
if auth_code:
|
|
1880
|
+
headers["X-Foggy-Runtime-Code"] = auth_code
|
|
1881
|
+
request = urllib.request.Request(
|
|
1882
|
+
f"{base_url}{path}",
|
|
1883
|
+
data=request_body,
|
|
1884
|
+
headers=headers,
|
|
1885
|
+
method=method,
|
|
1886
|
+
)
|
|
1887
|
+
try:
|
|
1888
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
1889
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
1890
|
+
except urllib.error.HTTPError as exc:
|
|
1891
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
1892
|
+
try:
|
|
1893
|
+
failed = json.loads(raw)
|
|
1894
|
+
except json.JSONDecodeError:
|
|
1895
|
+
failed = None
|
|
1896
|
+
error = failed.get("error") if isinstance(failed, dict) else None
|
|
1897
|
+
detail = (
|
|
1898
|
+
" | ".join(str(error.get(name)) for name in ("code", "phase", "message") if error.get(name))
|
|
1899
|
+
if isinstance(error, dict)
|
|
1900
|
+
else str(exc.reason)
|
|
1901
|
+
)
|
|
1902
|
+
raise OnboardingError(f"{label} failed with HTTP {exc.code}: {detail or exc.reason}") from exc
|
|
1903
|
+
except urllib.error.URLError as exc:
|
|
1904
|
+
raise OnboardingError(f"{label} could not reach Runtime: {exc.reason}") from exc
|
|
1905
|
+
try:
|
|
1906
|
+
payload = json.loads(raw)
|
|
1907
|
+
except json.JSONDecodeError as exc:
|
|
1908
|
+
raise OnboardingError(f"{label} did not return JSON") from exc
|
|
1909
|
+
if payload.get("success") is not True:
|
|
1910
|
+
error = payload.get("error")
|
|
1911
|
+
if isinstance(error, dict):
|
|
1912
|
+
detail = " | ".join(str(error.get(name)) for name in ("code", "phase", "message") if error.get(name))
|
|
1913
|
+
else:
|
|
1914
|
+
detail = str(error or "unknown Runtime error")
|
|
1915
|
+
raise OnboardingError(f"{label} returned success=false: {detail}")
|
|
1916
|
+
return payload
|
|
1917
|
+
|
|
1918
|
+
|
|
1478
1919
|
CONNECTION_SECRET_KEYS = {
|
|
1479
1920
|
"jdbcurl", "url", "username", "password", "passwordenv", "passwordref",
|
|
1480
1921
|
}
|
|
@@ -1532,7 +1973,8 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
|
|
|
1532
1973
|
if not project_root.is_dir():
|
|
1533
1974
|
raise OnboardingError(f"Project root not found: {project_root}")
|
|
1534
1975
|
connection_file = normalized(args.connection_file)
|
|
1535
|
-
|
|
1976
|
+
requested_connection = validate_connection(read_json_object(connection_file, "Connection plan"))
|
|
1977
|
+
connection = persisted_connection(requested_connection)
|
|
1536
1978
|
if connection.get("connectionMode") == "opaque-profile":
|
|
1537
1979
|
require_opaque_profile_cli(install_state)
|
|
1538
1980
|
existing = read_onboarding_state(data_root, profile, required=False)
|
|
@@ -1577,7 +2019,10 @@ def onboarding_plan_command(args: argparse.Namespace) -> dict:
|
|
|
1577
2019
|
"schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
|
|
1578
2020
|
"profile": profile,
|
|
1579
2021
|
"statePath": str(path),
|
|
1580
|
-
"connection": {
|
|
2022
|
+
"connection": {
|
|
2023
|
+
**connection,
|
|
2024
|
+
"passwordEnvPresent": bool(password_env and os.environ.get(password_env)),
|
|
2025
|
+
},
|
|
1581
2026
|
"runtimeAvailable": runtime_state is not None,
|
|
1582
2027
|
"next": "run datasource-configure --apply after reviewing the plan",
|
|
1583
2028
|
"productionReady": False,
|
|
@@ -1608,10 +2053,11 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
|
|
|
1608
2053
|
if opaque:
|
|
1609
2054
|
plan.update({"profileId": connection["opaqueProfileId"], "revision": connection["opaqueRevision"]})
|
|
1610
2055
|
else:
|
|
2056
|
+
inline_password = getattr(args, "runtime_password", None)
|
|
1611
2057
|
plan.update({
|
|
1612
2058
|
"jdbcUrl": connection["jdbcUrl"],
|
|
1613
2059
|
"username": connection.get("username"),
|
|
1614
|
-
"
|
|
2060
|
+
"credentialMode": connection.get("credentialMode", "none"),
|
|
1615
2061
|
})
|
|
1616
2062
|
if not args.apply:
|
|
1617
2063
|
return {"success": True, "dryRun": True, "profile": state["profile"], "plan": plan, "next": "rerun with --apply after approval"}
|
|
@@ -1623,22 +2069,50 @@ def datasource_configure_command(args: argparse.Namespace) -> dict:
|
|
|
1623
2069
|
]
|
|
1624
2070
|
label = "opaque profile configure"
|
|
1625
2071
|
else:
|
|
2072
|
+
inline_password = getattr(args, "runtime_password", None)
|
|
1626
2073
|
password_env = connection.get("passwordEnv")
|
|
1627
|
-
if
|
|
2074
|
+
if connection.get("credentialMode") == "inline-development" and inline_password is None:
|
|
2075
|
+
raise OnboardingError(
|
|
2076
|
+
"Direct development password is not persisted; rerun onboard-datasource-run with the original connection file"
|
|
2077
|
+
)
|
|
2078
|
+
if inline_password is None and password_env and os.environ.get(password_env) is None:
|
|
1628
2079
|
raise OnboardingError(f"Required password environment variable is not present: {password_env}")
|
|
1629
|
-
command = ["datasources", "add", "--name", connection["name"], "--type", connection["type"], "--jdbc-url", connection["jdbcUrl"]]
|
|
1630
|
-
if connection.get("username"):
|
|
1631
|
-
command.extend(["--username", connection["username"]])
|
|
1632
|
-
if password_env:
|
|
1633
|
-
command.extend(["--password-env", password_env])
|
|
1634
2080
|
label = "datasources add"
|
|
1635
|
-
|
|
1636
|
-
|
|
2081
|
+
resolved_password = inline_password if inline_password is not None else (
|
|
2082
|
+
os.environ.get(password_env) if password_env else None
|
|
2083
|
+
)
|
|
2084
|
+
command = None
|
|
1637
2085
|
already_present = False
|
|
1638
2086
|
try:
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
2087
|
+
if not opaque and resolved_password is not None:
|
|
2088
|
+
body = {
|
|
2089
|
+
"name": connection["name"],
|
|
2090
|
+
"type": connection["type"],
|
|
2091
|
+
"jdbcUrl": connection["jdbcUrl"],
|
|
2092
|
+
"replace": bool(args.replace),
|
|
2093
|
+
"enabled": True,
|
|
2094
|
+
"password": resolved_password,
|
|
2095
|
+
}
|
|
2096
|
+
if connection.get("username"):
|
|
2097
|
+
body["username"] = connection["username"]
|
|
2098
|
+
result = redact_connection_material(runtime_api_json(
|
|
2099
|
+
runtime_state,
|
|
2100
|
+
connection["namespace"],
|
|
2101
|
+
"POST",
|
|
2102
|
+
"/api/v1/datasources",
|
|
2103
|
+
body,
|
|
2104
|
+
label,
|
|
2105
|
+
))
|
|
2106
|
+
else:
|
|
2107
|
+
if not opaque:
|
|
2108
|
+
command = ["datasources", "add", "--name", connection["name"], "--type", connection["type"], "--jdbc-url", connection["jdbcUrl"]]
|
|
2109
|
+
if connection.get("username"):
|
|
2110
|
+
command.extend(["--username", connection["username"]])
|
|
2111
|
+
if args.replace:
|
|
2112
|
+
command.append("--replace")
|
|
2113
|
+
result = redact_connection_material(
|
|
2114
|
+
cli_json(install_state, runtime_state, connection["namespace"], command, label)
|
|
2115
|
+
)
|
|
1642
2116
|
except OnboardingError as exc:
|
|
1643
2117
|
if "DATASOURCE_ALREADY_EXISTS" not in str(exc):
|
|
1644
2118
|
raise
|
|
@@ -1679,9 +2153,8 @@ def datasource_verify_command(args: argparse.Namespace) -> dict:
|
|
|
1679
2153
|
raise OnboardingError("Datasource is not configured; run datasource-configure --apply first")
|
|
1680
2154
|
connection = state["connection"]
|
|
1681
2155
|
namespace = connection["namespace"]
|
|
1682
|
-
tested = redact_connection_material(
|
|
1683
|
-
install_state, runtime_state, namespace,
|
|
1684
|
-
["datasources", "test", connection["name"]], "datasources test",
|
|
2156
|
+
tested = redact_connection_material(datasource_test_with_retry(
|
|
2157
|
+
install_state, runtime_state, namespace, connection["name"],
|
|
1685
2158
|
))
|
|
1686
2159
|
if not args.bind:
|
|
1687
2160
|
mark_step(state, "datasourceVerified", "waiting-for-binding", connectionTested=True)
|
|
@@ -2054,6 +2527,52 @@ def nested_data_objects(payload: dict) -> list[dict]:
|
|
|
2054
2527
|
return objects
|
|
2055
2528
|
|
|
2056
2529
|
|
|
2530
|
+
def runtime_warnings(payload: dict, stage: str) -> list[dict]:
|
|
2531
|
+
"""Collect structured query-input and legacy warnings without inventing remediation."""
|
|
2532
|
+
warnings: list[dict] = []
|
|
2533
|
+
seen: set[str] = set()
|
|
2534
|
+
for item in nested_data_objects(payload):
|
|
2535
|
+
for field, kind in (("queryInputWarnings", "query-input"), ("warnings", "legacy")):
|
|
2536
|
+
raw = item.get(field)
|
|
2537
|
+
if raw is None:
|
|
2538
|
+
continue
|
|
2539
|
+
values = raw if isinstance(raw, list) else [raw]
|
|
2540
|
+
for value in values:
|
|
2541
|
+
warning = dict(value) if isinstance(value, dict) else {"message": str(value)}
|
|
2542
|
+
warning["warningKind"] = kind
|
|
2543
|
+
fingerprint = json.dumps(warning, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
2544
|
+
if fingerprint in seen:
|
|
2545
|
+
continue
|
|
2546
|
+
seen.add(fingerprint)
|
|
2547
|
+
warning["stages"] = [stage]
|
|
2548
|
+
warnings.append(warning)
|
|
2549
|
+
return warnings
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
def merge_runtime_warnings(*collections: list[dict]) -> list[dict]:
|
|
2553
|
+
"""Deduplicate the same warning while retaining every stage that emitted it."""
|
|
2554
|
+
merged: list[dict] = []
|
|
2555
|
+
indexes: dict[str, int] = {}
|
|
2556
|
+
for collection in collections:
|
|
2557
|
+
for value in collection:
|
|
2558
|
+
warning = dict(value)
|
|
2559
|
+
stages = warning.pop("stages", [])
|
|
2560
|
+
fingerprint = json.dumps(warning, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
2561
|
+
if fingerprint not in indexes:
|
|
2562
|
+
warning["stages"] = []
|
|
2563
|
+
indexes[fingerprint] = len(merged)
|
|
2564
|
+
merged.append(warning)
|
|
2565
|
+
target = merged[indexes[fingerprint]]["stages"]
|
|
2566
|
+
for stage in stages:
|
|
2567
|
+
if stage not in target:
|
|
2568
|
+
target.append(stage)
|
|
2569
|
+
return merged
|
|
2570
|
+
|
|
2571
|
+
|
|
2572
|
+
def runtime_warning_codes(warnings: list[dict]) -> list[str]:
|
|
2573
|
+
return sorted({str(item["code"]) for item in warnings if item.get("code")})
|
|
2574
|
+
|
|
2575
|
+
|
|
2057
2576
|
def response_field_count(payload: dict) -> int | None:
|
|
2058
2577
|
for item in nested_data_objects(payload):
|
|
2059
2578
|
fields = item.get("fields")
|
|
@@ -2104,8 +2623,18 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
|
|
|
2104
2623
|
["query", "validate", query_model, "--payload", str(payload_path)], "query validate", timeout=90,
|
|
2105
2624
|
)
|
|
2106
2625
|
atomic_json(evidence_dir / "query-validate.json", validated)
|
|
2626
|
+
validation_warnings = runtime_warnings(validated, "query-validate")
|
|
2627
|
+
validation_warning_codes = runtime_warning_codes(validation_warnings)
|
|
2107
2628
|
if not args.execute:
|
|
2108
|
-
mark_step(
|
|
2629
|
+
mark_step(
|
|
2630
|
+
state,
|
|
2631
|
+
"semanticVerified",
|
|
2632
|
+
"waiting-for-query-execution",
|
|
2633
|
+
queryModel=query_model,
|
|
2634
|
+
queryValidated=True,
|
|
2635
|
+
warningCount=len(validation_warnings),
|
|
2636
|
+
warningCodes=validation_warning_codes,
|
|
2637
|
+
)
|
|
2109
2638
|
state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
|
|
2110
2639
|
path = write_onboarding_state(data_root, state)
|
|
2111
2640
|
return {
|
|
@@ -2116,6 +2645,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
|
|
|
2116
2645
|
"queryModel": query_model,
|
|
2117
2646
|
"queryValidated": True,
|
|
2118
2647
|
"queryExecuted": False,
|
|
2648
|
+
"warningCount": len(validation_warnings),
|
|
2649
|
+
"warningCodes": validation_warning_codes,
|
|
2650
|
+
"warnings": validation_warnings,
|
|
2119
2651
|
"evidenceDir": str(evidence_dir),
|
|
2120
2652
|
"statePath": str(path),
|
|
2121
2653
|
"next": "rerun semantic-verify with --execute after approving the bounded business-data query",
|
|
@@ -2126,6 +2658,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
|
|
|
2126
2658
|
["query", "execute", query_model, "--payload", str(payload_path)], "query execute", timeout=120,
|
|
2127
2659
|
)
|
|
2128
2660
|
atomic_json(evidence_dir / "query-execute.json", executed)
|
|
2661
|
+
execution_warnings = runtime_warnings(executed, "query-execute")
|
|
2662
|
+
warnings = merge_runtime_warnings(validation_warnings, execution_warnings)
|
|
2663
|
+
warning_codes = runtime_warning_codes(warnings)
|
|
2129
2664
|
row_count = response_row_count(executed)
|
|
2130
2665
|
mark_step(
|
|
2131
2666
|
state,
|
|
@@ -2137,6 +2672,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
|
|
|
2137
2672
|
rowCount=row_count,
|
|
2138
2673
|
queryPayloadDigest=sha256(payload_path),
|
|
2139
2674
|
projectRoot=str(project_root),
|
|
2675
|
+
warningCount=len(warnings),
|
|
2676
|
+
warningCodes=warning_codes,
|
|
2677
|
+
warningEvidence=str(evidence_dir),
|
|
2140
2678
|
)
|
|
2141
2679
|
workspace_verifications = state.setdefault("workspaceVerifications", [])
|
|
2142
2680
|
workspace_verifications[:] = [
|
|
@@ -2148,6 +2686,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
|
|
|
2148
2686
|
"queryModel": query_model,
|
|
2149
2687
|
"queryPayloadDigest": sha256(payload_path),
|
|
2150
2688
|
"rowCount": row_count,
|
|
2689
|
+
"warningCount": len(warnings),
|
|
2690
|
+
"warningCodes": warning_codes,
|
|
2691
|
+
"warningEvidence": str(evidence_dir),
|
|
2151
2692
|
"verifiedAt": now_utc(),
|
|
2152
2693
|
})
|
|
2153
2694
|
state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
|
|
@@ -2161,6 +2702,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
|
|
|
2161
2702
|
"queryValidated": True,
|
|
2162
2703
|
"queryExecuted": True,
|
|
2163
2704
|
"rowCount": row_count,
|
|
2705
|
+
"warningCount": len(warnings),
|
|
2706
|
+
"warningCodes": warning_codes,
|
|
2707
|
+
"warnings": warnings,
|
|
2164
2708
|
"evidenceDir": str(evidence_dir),
|
|
2165
2709
|
"statePath": str(path),
|
|
2166
2710
|
"next": "onboarding complete; begin analysis with described field names",
|
|
@@ -2242,6 +2786,9 @@ def onboarding_list_command(args: argparse.Namespace) -> dict:
|
|
|
2242
2786
|
"completedSteps": completed,
|
|
2243
2787
|
"totalSteps": len(ordered_steps),
|
|
2244
2788
|
"steps": {name: steps.get(name, {"status": "pending"}) for name in ordered_steps},
|
|
2789
|
+
"warningCount": steps.get("semanticVerified", {}).get("warningCount", 0),
|
|
2790
|
+
"warningCodes": steps.get("semanticVerified", {}).get("warningCodes", []),
|
|
2791
|
+
"warningEvidence": steps.get("semanticVerified", {}).get("warningEvidence"),
|
|
2245
2792
|
"next": next_onboarding_action(state),
|
|
2246
2793
|
})
|
|
2247
2794
|
except OnboardingError as exc:
|
|
@@ -2301,6 +2848,38 @@ def step_completed(state: dict, name: str) -> bool:
|
|
|
2301
2848
|
return state.get("steps", {}).get(name, {}).get("status") == "completed"
|
|
2302
2849
|
|
|
2303
2850
|
|
|
2851
|
+
def reconcile_datasource_checkpoint(
|
|
2852
|
+
state: dict,
|
|
2853
|
+
install_state: dict,
|
|
2854
|
+
data_root: Path,
|
|
2855
|
+
runtime_state: dict,
|
|
2856
|
+
) -> dict | None:
|
|
2857
|
+
"""Reset dependent checkpoints when the live Runtime lost the datasource."""
|
|
2858
|
+
if not step_completed(state, "datasourceConfigured"):
|
|
2859
|
+
return None
|
|
2860
|
+
connection = state["connection"]
|
|
2861
|
+
listed = cli_json(
|
|
2862
|
+
install_state,
|
|
2863
|
+
runtime_state,
|
|
2864
|
+
connection["namespace"],
|
|
2865
|
+
["datasources", "list"],
|
|
2866
|
+
"datasources list for checkpoint reconciliation",
|
|
2867
|
+
)
|
|
2868
|
+
if matching_datasource(listed, connection) is not None:
|
|
2869
|
+
return None
|
|
2870
|
+
reset_steps = ("datasourceConfigured", "datasourceVerified", "schemaDiscovered")
|
|
2871
|
+
for name in reset_steps:
|
|
2872
|
+
mark_step(state, name, "pending", reason="runtime-datasource-missing")
|
|
2873
|
+
state.get("artifacts", {}).pop("schemaDiscovery", None)
|
|
2874
|
+
write_onboarding_state(data_root, state)
|
|
2875
|
+
return {
|
|
2876
|
+
"recovered": True,
|
|
2877
|
+
"reason": "runtime-datasource-missing",
|
|
2878
|
+
"resetSteps": list(reset_steps),
|
|
2879
|
+
"dataSource": connection["name"],
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
|
|
2304
2883
|
def bound_project_roots(state: dict) -> list[Path]:
|
|
2305
2884
|
values = [state.get("projectRoot"), *state.get("workspaceBindings", [])]
|
|
2306
2885
|
result: list[Path] = []
|
|
@@ -2333,9 +2912,10 @@ def bind_completed_workspace(state: dict, data_root: Path, project_root: Path) -
|
|
|
2333
2912
|
|
|
2334
2913
|
|
|
2335
2914
|
def datasource_run_command(args: argparse.Namespace) -> dict:
|
|
2336
|
-
_install_root, install_state, data_root,
|
|
2915
|
+
_install_root, install_state, data_root, runtime_state = onboarding_context(args, require_runtime=True)
|
|
2337
2916
|
project_root = normalized(args.project_root or Path.cwd())
|
|
2338
2917
|
requested_connection = validate_connection(read_json_object(normalized(args.connection_file), "Connection plan"))
|
|
2918
|
+
resumable_connection = persisted_connection(requested_connection)
|
|
2339
2919
|
if not requested_connection.get("profile"):
|
|
2340
2920
|
raise OnboardingError("Composite datasource onboarding requires connection.profile in the approved contract")
|
|
2341
2921
|
profile = requested_connection["profile"]
|
|
@@ -2349,7 +2929,7 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
|
|
|
2349
2929
|
files: list[str] = []
|
|
2350
2930
|
existing = read_onboarding_state(data_root, profile, required=False)
|
|
2351
2931
|
if existing:
|
|
2352
|
-
if existing.get("connection") !=
|
|
2932
|
+
if persisted_connection(existing.get("connection", {})) != resumable_connection:
|
|
2353
2933
|
raise OnboardingError("Existing onboarding profile does not match the requested connection plan")
|
|
2354
2934
|
adopted = bind_completed_workspace(existing, data_root, project_root)
|
|
2355
2935
|
plan_result = {
|
|
@@ -2374,6 +2954,9 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
|
|
|
2374
2954
|
))
|
|
2375
2955
|
save_composite_result(evidence_dir, "01-plan.json", plan_result, files)
|
|
2376
2956
|
state = read_onboarding_state(data_root, profile)
|
|
2957
|
+
checkpoint_recovery = reconcile_datasource_checkpoint(
|
|
2958
|
+
state, install_state, data_root, runtime_state,
|
|
2959
|
+
)
|
|
2377
2960
|
|
|
2378
2961
|
if step_completed(state, "datasourceConfigured"):
|
|
2379
2962
|
configured = resumed_phase(profile, "datasourceConfigured")
|
|
@@ -2382,7 +2965,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
|
|
|
2382
2965
|
else:
|
|
2383
2966
|
configure_dry = datasource_configure_command(argparse.Namespace(
|
|
2384
2967
|
install_root=args.install_root, data_root=args.data_root, profile=profile, apply=False, replace=False,
|
|
2968
|
+
runtime_password=requested_connection.get("password"),
|
|
2385
2969
|
))
|
|
2970
|
+
if checkpoint_recovery:
|
|
2971
|
+
configure_dry["checkpointRecovery"] = checkpoint_recovery
|
|
2386
2972
|
save_composite_result(evidence_dir, "02-datasource-dry.json", configure_dry, files)
|
|
2387
2973
|
if not args.approve_configure:
|
|
2388
2974
|
return {
|
|
@@ -2397,7 +2983,10 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
|
|
|
2397
2983
|
}
|
|
2398
2984
|
configured = datasource_configure_command(argparse.Namespace(
|
|
2399
2985
|
install_root=args.install_root, data_root=args.data_root, profile=profile, apply=True, replace=False,
|
|
2986
|
+
runtime_password=requested_connection.get("password"),
|
|
2400
2987
|
))
|
|
2988
|
+
if checkpoint_recovery:
|
|
2989
|
+
configured["checkpointRecovery"] = checkpoint_recovery
|
|
2401
2990
|
save_composite_result(evidence_dir, "03-datasource-apply.json", configured, files)
|
|
2402
2991
|
state = read_onboarding_state(data_root, profile)
|
|
2403
2992
|
|
|
@@ -2662,6 +3251,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
|
|
|
2662
3251
|
"queryValidated": True,
|
|
2663
3252
|
"queryExecuted": True,
|
|
2664
3253
|
"rowCount": verified_step.get("rowCount"),
|
|
3254
|
+
"warningCount": verified_step.get("warningCount", 0),
|
|
3255
|
+
"warningCodes": verified_step.get("warningCodes", []),
|
|
3256
|
+
"warningEvidence": verified_step.get("warningEvidence"),
|
|
2665
3257
|
"evidenceDir": str(evidence_dir),
|
|
2666
3258
|
"evidenceFiles": files,
|
|
2667
3259
|
"productionReady": False,
|
|
@@ -2684,6 +3276,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
|
|
|
2684
3276
|
"phaseStatus": "awaiting-query-execution-approval",
|
|
2685
3277
|
"queryValidated": True,
|
|
2686
3278
|
"queryExecuted": False,
|
|
3279
|
+
"warningCount": query_validated.get("warningCount", 0),
|
|
3280
|
+
"warningCodes": query_validated.get("warningCodes", []),
|
|
3281
|
+
"warnings": query_validated.get("warnings", []),
|
|
2687
3282
|
"evidenceDir": str(evidence_dir),
|
|
2688
3283
|
"evidenceFiles": files,
|
|
2689
3284
|
"next": "rerun with all three approval flags after bounded query execution is approved",
|
|
@@ -2715,6 +3310,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
|
|
|
2715
3310
|
"queryValidated": executed["queryValidated"],
|
|
2716
3311
|
"queryExecuted": executed["queryExecuted"],
|
|
2717
3312
|
"rowCount": executed["rowCount"],
|
|
3313
|
+
"warningCount": executed.get("warningCount", 0),
|
|
3314
|
+
"warningCodes": executed.get("warningCodes", []),
|
|
3315
|
+
"warnings": executed.get("warnings", []),
|
|
2718
3316
|
"evidenceDir": str(evidence_dir),
|
|
2719
3317
|
"evidenceFiles": files,
|
|
2720
3318
|
"productionReady": False,
|
|
@@ -2740,8 +3338,10 @@ def doctor_command(args: argparse.Namespace) -> dict:
|
|
|
2740
3338
|
launcher_checks.append({"file": asset["file"], "present": path.is_file(), "sha256Valid": path.is_file() and sha256(path) == asset["sha256"]})
|
|
2741
3339
|
launcher_ok = bool(launcher_checks) and all(item["present"] and item["sha256Valid"] for item in launcher_checks)
|
|
2742
3340
|
analysis_skill_root = normalized(state.get("skills", {}).get("analysis", {}).get("path") or install_root / "skills" / "foggy-ai-analysis") if state else install_root / "skills" / "foggy-ai-analysis"
|
|
3341
|
+
semantic_query_skill_root = normalized(state.get("skills", {}).get("semanticQuery", {}).get("path") or install_root / "skills" / "foggy-semantic-query") if state else install_root / "skills" / "foggy-semantic-query"
|
|
2743
3342
|
onboarding_skill_root = skill_root()
|
|
2744
3343
|
analysis_skill = managed_skill_status(analysis_skill_root, "analysis", versions["components"]["analysisSkill"]["version"])
|
|
3344
|
+
semantic_query_skill = managed_skill_status(semantic_query_skill_root, "semantic-query", versions["components"]["semanticQuerySkill"]["version"])
|
|
2745
3345
|
onboarding_skill = {
|
|
2746
3346
|
"path": str(onboarding_skill_root),
|
|
2747
3347
|
"present": (onboarding_skill_root / "SKILL.md").is_file(),
|
|
@@ -2764,6 +3364,7 @@ def doctor_command(args: argparse.Namespace) -> dict:
|
|
|
2764
3364
|
"cli": cli_ok,
|
|
2765
3365
|
"launcher": launcher_ok,
|
|
2766
3366
|
"analysisSkill": analysis_skill["valid"],
|
|
3367
|
+
"semanticQuerySkill": semantic_query_skill["valid"],
|
|
2767
3368
|
"onboardingSkill": onboarding_skill["valid"],
|
|
2768
3369
|
}
|
|
2769
3370
|
if args.strict_runtime:
|
|
@@ -2778,7 +3379,7 @@ def doctor_command(args: argparse.Namespace) -> dict:
|
|
|
2778
3379
|
"java": java,
|
|
2779
3380
|
"cli": cli,
|
|
2780
3381
|
"launcherAssets": launcher_checks,
|
|
2781
|
-
"skills": {"analysis": analysis_skill, "onboarding": onboarding_skill},
|
|
3382
|
+
"skills": {"analysis": analysis_skill, "semanticQuery": semantic_query_skill, "onboarding": onboarding_skill},
|
|
2782
3383
|
"workspace": {"path": str(project_root), "mode": "dsh-session-cwd"},
|
|
2783
3384
|
"runtime": runtime,
|
|
2784
3385
|
"environmentPresence": {name: bool(os.environ.get(name)) for name in ("DEEPSEEK_API_KEY", "ALIYUN_TOKEN_PLAN_API_KEY", "FOGGY_RUNTIME_API_AUTH_CODE", "FOGGY_RUNTIME_AUTHORIZATION")},
|
|
@@ -2796,7 +3397,7 @@ def uninstall_command(args: argparse.Namespace) -> dict:
|
|
|
2796
3397
|
if install_root == data_root:
|
|
2797
3398
|
raise OnboardingError("Install root and data root must be different")
|
|
2798
3399
|
skills_root = install_root / "skills"
|
|
2799
|
-
skill_targets = [skills_root / "foggy-ai-analysis"]
|
|
3400
|
+
skill_targets = [skills_root / "foggy-ai-analysis", skills_root / "foggy-semantic-query"]
|
|
2800
3401
|
plan = {"installRoot": str(install_root), "dataRoot": str(data_root), "removeSkills": args.remove_skills, "purgeData": args.purge_data}
|
|
2801
3402
|
if args.dry_run:
|
|
2802
3403
|
return {"success": True, "dryRun": True, "plan": plan}
|
|
@@ -2831,7 +3432,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2831
3432
|
install.add_argument("--project-root")
|
|
2832
3433
|
install.add_argument("--asset-cache-dir", action="append", default=[])
|
|
2833
3434
|
install.add_argument("--replace-skill", action="store_true")
|
|
2834
|
-
install.add_argument("--repair-component", choices=("cli", "launcher", "analysis-skill"))
|
|
3435
|
+
install.add_argument("--repair-component", choices=("cli", "launcher", "analysis-skill", "semantic-query-skill"))
|
|
2835
3436
|
install.add_argument("--skip-cli-install", action="store_true")
|
|
2836
3437
|
install.add_argument("--cli-command")
|
|
2837
3438
|
install.add_argument("--progress-file")
|
|
@@ -2855,6 +3456,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2855
3456
|
start.add_argument("--namespace")
|
|
2856
3457
|
start.add_argument("--timeout", type=int)
|
|
2857
3458
|
start.add_argument("--java")
|
|
3459
|
+
start.add_argument("--progress-file")
|
|
3460
|
+
start.add_argument("--operation-id")
|
|
3461
|
+
start.add_argument("--operation-kind", default="runtime-start")
|
|
2858
3462
|
start.set_defaults(handler=runtime_start_command)
|
|
2859
3463
|
|
|
2860
3464
|
stop = sub.add_parser("runtime-stop")
|
|
@@ -3006,6 +3610,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3006
3610
|
|
|
3007
3611
|
|
|
3008
3612
|
def main() -> None:
|
|
3613
|
+
for stream in (sys.stdout, sys.stderr):
|
|
3614
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
3615
|
+
if callable(reconfigure):
|
|
3616
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
3009
3617
|
args = build_parser().parse_args()
|
|
3010
3618
|
try:
|
|
3011
3619
|
result = args.handler(args)
|
|
@@ -3016,7 +3624,7 @@ def main() -> None:
|
|
|
3016
3624
|
except OnboardingError as exc:
|
|
3017
3625
|
if ACTIVE_PROGRESS is not None:
|
|
3018
3626
|
ACTIVE_PROGRESS.fail(exc)
|
|
3019
|
-
emit({"success": False, "error": {"code": "ONBOARDING_ERROR", "message": str(exc)}, "productionReady": False}, 1)
|
|
3627
|
+
emit({"success": False, "error": {"code": getattr(exc, "code", "ONBOARDING_ERROR"), "message": str(exc)}, "productionReady": False}, 1)
|
|
3020
3628
|
except Exception as exc:
|
|
3021
3629
|
if ACTIVE_PROGRESS is not None:
|
|
3022
3630
|
ACTIVE_PROGRESS.fail(exc)
|