mindsync-ai 1.1.0__py3-none-any.whl
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.
- mindsync/__init__.py +3 -0
- mindsync/bridge.py +516 -0
- mindsync/bus/__init__.py +16 -0
- mindsync/bus/events.py +187 -0
- mindsync/bus/models.py +147 -0
- mindsync/config.py +131 -0
- mindsync/conflict.py +117 -0
- mindsync/dispatch/__init__.py +19 -0
- mindsync/dispatch/__main__.py +4 -0
- mindsync/dispatch/adapters.py +149 -0
- mindsync/dispatch/cli.py +134 -0
- mindsync/dispatch/presets/aider.json +11 -0
- mindsync/dispatch/presets/claude.json +11 -0
- mindsync/dispatch/presets/codex.json +12 -0
- mindsync/dispatch/presets/cursor.json +11 -0
- mindsync/dispatch/presets/gemini.json +11 -0
- mindsync/dispatch/presets/grok.json +11 -0
- mindsync/dispatch/proc.py +271 -0
- mindsync/dispatch/runner.py +239 -0
- mindsync/dispatch/store.py +141 -0
- mindsync/server.py +715 -0
- mindsync/storage.py +440 -0
- mindsync_ai-1.1.0.dist-info/METADATA +248 -0
- mindsync_ai-1.1.0.dist-info/RECORD +28 -0
- mindsync_ai-1.1.0.dist-info/WHEEL +5 -0
- mindsync_ai-1.1.0.dist-info/entry_points.txt +3 -0
- mindsync_ai-1.1.0.dist-info/licenses/LICENSE +21 -0
- mindsync_ai-1.1.0.dist-info/top_level.txt +1 -0
mindsync/__init__.py
ADDED
mindsync/bridge.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""SSH/SCP bridge to an optional remote durable store.
|
|
2
|
+
|
|
3
|
+
Remote layout is configurable. Defaults match the sample scripts under
|
|
4
|
+
``examples/remote/``. No host, username, or server path is hard-coded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
import threading
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from mindsync.config import settings
|
|
20
|
+
|
|
21
|
+
# Safe identifier patterns (no shell metacharacters, path traversal, or colon)
|
|
22
|
+
_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
23
|
+
_SAFE_SOURCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
24
|
+
_SAFE_HEX = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
25
|
+
|
|
26
|
+
_FORBIDDEN_WINDOWS = {
|
|
27
|
+
"CON", "PRN", "AUX", "NUL",
|
|
28
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
29
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def validate_agent(val: str) -> str:
|
|
33
|
+
if not val or not _SAFE_ID.match(val):
|
|
34
|
+
raise ValueError(f"Invalid agent name {val!r}: must be alphanumeric + _ . - (max 128)")
|
|
35
|
+
return val
|
|
36
|
+
|
|
37
|
+
def _check_windows_safe(val: str) -> None:
|
|
38
|
+
upper = val.upper()
|
|
39
|
+
base = upper.split(".")[0]
|
|
40
|
+
if base in _FORBIDDEN_WINDOWS:
|
|
41
|
+
raise ValueError(f"Reserved Windows device name: {val}")
|
|
42
|
+
for char in ('<', '>', ':', '"', '/', '\\', '|', '?', '*'):
|
|
43
|
+
if char in val:
|
|
44
|
+
raise ValueError(f"Forbidden character {char!r} in {val}")
|
|
45
|
+
|
|
46
|
+
def validate_entity(val: str) -> str:
|
|
47
|
+
if not val or not _SAFE_ID.match(val):
|
|
48
|
+
raise ValueError(f"Invalid entity name {val!r}: must be alphanumeric + _ . - (max 128)")
|
|
49
|
+
_check_windows_safe(val)
|
|
50
|
+
return val
|
|
51
|
+
|
|
52
|
+
def validate_attribute(val: str) -> str:
|
|
53
|
+
if not val or not _SAFE_ID.match(val):
|
|
54
|
+
raise ValueError(f"Invalid attribute name {val!r}: must be alphanumeric + _ . - (max 128)")
|
|
55
|
+
_check_windows_safe(val)
|
|
56
|
+
return val
|
|
57
|
+
|
|
58
|
+
def validate_source(val: str) -> str:
|
|
59
|
+
if not val or not _SAFE_SOURCE.match(val):
|
|
60
|
+
raise ValueError(f"Invalid source {val!r}: must be alphanumeric + _ . : - (max 128)")
|
|
61
|
+
for char in ('<', '>', '"', '/', '\\', '|', '?', '*'):
|
|
62
|
+
if char in val:
|
|
63
|
+
raise ValueError(f"Forbidden character {char!r} in source {val}")
|
|
64
|
+
return val
|
|
65
|
+
|
|
66
|
+
def validate_fact_id(val: str) -> str:
|
|
67
|
+
if not val or not _SAFE_HEX.match(val):
|
|
68
|
+
raise ValueError(f"Invalid fact_id {val!r}")
|
|
69
|
+
return val
|
|
70
|
+
|
|
71
|
+
def validate_fact_text(val: str) -> str:
|
|
72
|
+
if not val:
|
|
73
|
+
raise ValueError("Fact text cannot be empty")
|
|
74
|
+
# Enforce limit on UTF-8 byte size
|
|
75
|
+
if len(val.encode("utf-8")) > 50 * 1024:
|
|
76
|
+
raise ValueError("Fact text exceeds 50KB limit")
|
|
77
|
+
return val
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# SSH key filenames and the .ssh dir are redacted explicitly (not just as
|
|
81
|
+
# part of a full-path match) because they can show up as bare fragments in
|
|
82
|
+
# OSError/subprocess messages without a recognizable leading path prefix,
|
|
83
|
+
# e.g. "Permission denied (publickey) ... .ssh\id_ed25519".
|
|
84
|
+
_KEY_FILE_RE = re.compile(r"id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?", re.IGNORECASE)
|
|
85
|
+
_SSH_DIR_RE = re.compile(r"\.ssh\b")
|
|
86
|
+
# Full absolute path matches (greedy to end-of-token, not just the first two
|
|
87
|
+
# segments) so nothing after e.g. "/home/user" survives, on POSIX or Windows.
|
|
88
|
+
_HOME_TILDE_RE = re.compile(r"~[/\\][^\s'\"]*")
|
|
89
|
+
_WINDOWS_PATH_RE = re.compile(r"[A-Za-z]:\\[^\s'\"]+")
|
|
90
|
+
_POSIX_PATH_RE = re.compile(r"/[^\s'\"]+")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sanitize_error(err: str) -> str:
|
|
94
|
+
if not err:
|
|
95
|
+
return "unknown error"
|
|
96
|
+
if settings.ssh_host:
|
|
97
|
+
err = err.replace(settings.ssh_host, "[SSH_HOST]")
|
|
98
|
+
if settings.remote_root:
|
|
99
|
+
err = err.replace(settings.remote_root, "[REMOTE_ROOT]")
|
|
100
|
+
# Scrub SSH key/identity references first, since they can appear as
|
|
101
|
+
# bare fragments that a path-shaped regex wouldn't otherwise catch.
|
|
102
|
+
err = _KEY_FILE_RE.sub("[KEY_FILE]", err)
|
|
103
|
+
err = _SSH_DIR_RE.sub("[SSH_DIR]", err)
|
|
104
|
+
# Scrub full absolute paths and home-relative (~) paths.
|
|
105
|
+
err = _HOME_TILDE_RE.sub("[PATH]", err)
|
|
106
|
+
err = _WINDOWS_PATH_RE.sub("[PATH]", err)
|
|
107
|
+
err = _POSIX_PATH_RE.sub("[PATH]", err)
|
|
108
|
+
return err
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_remote_cache: dict[str, Any] = {"status": "unknown", "reason": None, "checked_at": 0.0, "duration": 0.0}
|
|
112
|
+
_probe_lock = threading.Lock()
|
|
113
|
+
|
|
114
|
+
class BridgeError(Exception):
|
|
115
|
+
"""Remote bridge failure."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run(
|
|
119
|
+
args: list[str],
|
|
120
|
+
*,
|
|
121
|
+
timeout: float | None = None,
|
|
122
|
+
check: bool = True,
|
|
123
|
+
) -> subprocess.CompletedProcess[str]:
|
|
124
|
+
return subprocess.run(
|
|
125
|
+
args,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
timeout=timeout,
|
|
129
|
+
check=check,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def remote_not_configured_error() -> str:
|
|
134
|
+
return (
|
|
135
|
+
"Remote sync is not configured. Set MINDSYNC_SSH_HOST and "
|
|
136
|
+
"MINDSYNC_REMOTE_ROOT (see .env.example)."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def get_remote_status(*, force: bool = False) -> dict[str, Any]:
|
|
140
|
+
if not settings.remote_enabled:
|
|
141
|
+
return {"status": "unknown", "reason": "remote_not_configured", "checked_at": 0.0, "duration": 0.0, "cache_age": 0.0}
|
|
142
|
+
|
|
143
|
+
now = time.time()
|
|
144
|
+
ttl = settings.remote_cache_ttl_seconds
|
|
145
|
+
|
|
146
|
+
with _probe_lock:
|
|
147
|
+
cache_age = now - float(_remote_cache["checked_at"])
|
|
148
|
+
if not force and _remote_cache["checked_at"] > 0 and cache_age < ttl:
|
|
149
|
+
res = dict(_remote_cache)
|
|
150
|
+
res["cache_age"] = cache_age
|
|
151
|
+
return res
|
|
152
|
+
|
|
153
|
+
start = time.time()
|
|
154
|
+
status = "offline"
|
|
155
|
+
reason = "unknown"
|
|
156
|
+
try:
|
|
157
|
+
res = _run(
|
|
158
|
+
[
|
|
159
|
+
"ssh",
|
|
160
|
+
"-o",
|
|
161
|
+
"BatchMode=yes",
|
|
162
|
+
"-o",
|
|
163
|
+
f"ConnectTimeout={settings.ssh_connect_timeout}",
|
|
164
|
+
settings.ssh_host,
|
|
165
|
+
"echo",
|
|
166
|
+
"1",
|
|
167
|
+
],
|
|
168
|
+
timeout=settings.ssh_connect_timeout + 2,
|
|
169
|
+
check=False,
|
|
170
|
+
)
|
|
171
|
+
if res.returncode == 0:
|
|
172
|
+
status = "online"
|
|
173
|
+
reason = "ok"
|
|
174
|
+
elif res.returncode == 255:
|
|
175
|
+
status = "offline"
|
|
176
|
+
reason = "ssh_auth_or_timeout"
|
|
177
|
+
else:
|
|
178
|
+
status = "offline"
|
|
179
|
+
reason = f"exit_code_{res.returncode}"
|
|
180
|
+
except subprocess.TimeoutExpired:
|
|
181
|
+
status = "offline"
|
|
182
|
+
reason = "timeout"
|
|
183
|
+
except OSError:
|
|
184
|
+
status = "offline"
|
|
185
|
+
reason = "os_error"
|
|
186
|
+
|
|
187
|
+
duration = time.time() - start
|
|
188
|
+
_remote_cache.update({
|
|
189
|
+
"status": status,
|
|
190
|
+
"reason": reason,
|
|
191
|
+
"checked_at": time.time(),
|
|
192
|
+
"duration": round(duration, 3)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
res = dict(_remote_cache)
|
|
196
|
+
res["cache_age"] = 0.0
|
|
197
|
+
return res
|
|
198
|
+
|
|
199
|
+
def check_remote_online(*, force: bool = False) -> bool:
|
|
200
|
+
"""Cached SSH reachability probe. False when remote is disabled or unreachable."""
|
|
201
|
+
return get_remote_status(force=force)["status"] == "online"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Backward-compatible alias used during the rename.
|
|
205
|
+
check_vps_online = check_remote_online
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _ssh_script(script: str, *, timeout: float = 60) -> subprocess.CompletedProcess[str]:
|
|
209
|
+
"""Run a bash script on the remote host via stdin (avoids local shell quoting).
|
|
210
|
+
|
|
211
|
+
Always normalizes to LF bytes: Windows text mode would otherwise send CRLF
|
|
212
|
+
and break remote bash (`set -o pipefail\r`).
|
|
213
|
+
"""
|
|
214
|
+
normalized = script.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8")
|
|
215
|
+
res = subprocess.run(
|
|
216
|
+
[
|
|
217
|
+
"ssh",
|
|
218
|
+
"-o",
|
|
219
|
+
"BatchMode=yes",
|
|
220
|
+
"-o",
|
|
221
|
+
f"ConnectTimeout={settings.ssh_connect_timeout}",
|
|
222
|
+
settings.ssh_host,
|
|
223
|
+
"bash",
|
|
224
|
+
"-s",
|
|
225
|
+
],
|
|
226
|
+
input=normalized,
|
|
227
|
+
capture_output=True,
|
|
228
|
+
timeout=timeout,
|
|
229
|
+
check=False,
|
|
230
|
+
)
|
|
231
|
+
return subprocess.CompletedProcess(
|
|
232
|
+
args=res.args,
|
|
233
|
+
returncode=res.returncode,
|
|
234
|
+
stdout=(res.stdout or b"").decode("utf-8", errors="replace"),
|
|
235
|
+
stderr=(res.stderr or b"").decode("utf-8", errors="replace"),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _maybe_source_env() -> str:
|
|
240
|
+
"""Optional remote env file source lines."""
|
|
241
|
+
env_file = settings.remote_env_file
|
|
242
|
+
if not env_file:
|
|
243
|
+
return ""
|
|
244
|
+
return f"""
|
|
245
|
+
if [ -f {shlex.quote(env_file)} ]; then
|
|
246
|
+
set -a
|
|
247
|
+
# shellcheck disable=SC1090
|
|
248
|
+
source {shlex.quote(env_file)}
|
|
249
|
+
set +a
|
|
250
|
+
fi
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class WriteResult:
|
|
256
|
+
ok: bool
|
|
257
|
+
stdout: str = ""
|
|
258
|
+
stderr: str = ""
|
|
259
|
+
error: str | None = None
|
|
260
|
+
results: dict[str, Any] | None = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _looks_like_unsupported_batch_subcommand(result: "WriteResult") -> bool:
|
|
264
|
+
"""Detect an old, write-only remote writer that predates the `batch`
|
|
265
|
+
subcommand (argparse rejects it with "invalid choice: 'batch'", exit 2).
|
|
266
|
+
|
|
267
|
+
This doubles as the capability probe: rather than spend an extra SSH
|
|
268
|
+
round trip proactively asking the remote what it supports, the first
|
|
269
|
+
real batch attempt IS the probe -- if it fails with this specific
|
|
270
|
+
signature we know the remote is old and fall back, otherwise the normal
|
|
271
|
+
(and far more common) path costs nothing extra.
|
|
272
|
+
"""
|
|
273
|
+
text = f"{result.stderr or ''}\n{result.stdout or ''}".lower()
|
|
274
|
+
return "invalid choice" in text and "batch" in text
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _write_batch_native(valid_facts: list[dict[str, Any]]) -> WriteResult:
|
|
278
|
+
"""Send the whole batch in one call via the `batch` subcommand (new remote)."""
|
|
279
|
+
payload = json.dumps(valid_facts)
|
|
280
|
+
b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
|
|
281
|
+
remote_root = settings.remote_root
|
|
282
|
+
write_script = settings.remote_write_script
|
|
283
|
+
script = f"""set -euo pipefail
|
|
284
|
+
cd {shlex.quote(remote_root)}
|
|
285
|
+
{_maybe_source_env()}
|
|
286
|
+
PAYLOAD=$(printf '%s' {shlex.quote(b64)} | base64 -d)
|
|
287
|
+
python3 {shlex.quote(write_script)} batch --payload "$PAYLOAD"
|
|
288
|
+
"""
|
|
289
|
+
try:
|
|
290
|
+
res = _ssh_script(script, timeout=90)
|
|
291
|
+
except subprocess.TimeoutExpired:
|
|
292
|
+
return WriteResult(ok=False, error="SSH write timed out")
|
|
293
|
+
except OSError as exc:
|
|
294
|
+
return WriteResult(ok=False, error=_sanitize_error(f"SSH write failed: {exc}"))
|
|
295
|
+
|
|
296
|
+
if res.returncode != 0:
|
|
297
|
+
err = (res.stderr or res.stdout or "unknown remote error").strip()
|
|
298
|
+
return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=_sanitize_error(err))
|
|
299
|
+
|
|
300
|
+
parsed_results = None
|
|
301
|
+
try:
|
|
302
|
+
parsed_results = json.loads(res.stdout)
|
|
303
|
+
except json.JSONDecodeError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
return WriteResult(ok=True, stdout=res.stdout.strip(), stderr=res.stderr, results=parsed_results)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _write_single_via_legacy_protocol(fact: dict[str, Any]) -> WriteResult:
|
|
310
|
+
"""Write one fact using the older single-write-only remote protocol."""
|
|
311
|
+
write_script = settings.remote_write_script
|
|
312
|
+
args = " ".join(
|
|
313
|
+
[
|
|
314
|
+
f"--fact_id {shlex.quote(fact['fact_id'])}",
|
|
315
|
+
f"--agent {shlex.quote(fact['agent'])}",
|
|
316
|
+
f"--entity {shlex.quote(fact['entity'])}",
|
|
317
|
+
f"--attribute {shlex.quote(fact['attribute'])}",
|
|
318
|
+
f"--text {shlex.quote(fact['text'])}",
|
|
319
|
+
f"--source {shlex.quote(fact['source'])}",
|
|
320
|
+
f"--confidence {shlex.quote(str(fact['confidence']))}",
|
|
321
|
+
]
|
|
322
|
+
)
|
|
323
|
+
script = f"""set -euo pipefail
|
|
324
|
+
cd {shlex.quote(settings.remote_root)}
|
|
325
|
+
{_maybe_source_env()}
|
|
326
|
+
python3 {shlex.quote(write_script)} write {args}
|
|
327
|
+
"""
|
|
328
|
+
try:
|
|
329
|
+
res = _ssh_script(script, timeout=30)
|
|
330
|
+
except subprocess.TimeoutExpired:
|
|
331
|
+
return WriteResult(ok=False, error="SSH write timed out")
|
|
332
|
+
except OSError as exc:
|
|
333
|
+
return WriteResult(ok=False, error=f"SSH write failed: {exc}")
|
|
334
|
+
|
|
335
|
+
if res.returncode != 0:
|
|
336
|
+
err = (res.stderr or res.stdout or "unknown remote error").strip()
|
|
337
|
+
return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=err)
|
|
338
|
+
return WriteResult(ok=True, stdout=res.stdout.strip(), stderr=res.stderr)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _write_batch_via_legacy_single_writes(valid_facts: list[dict[str, Any]]) -> WriteResult:
|
|
342
|
+
"""Fallback for an already-deployed write-only remote: send facts one at
|
|
343
|
+
a time via the legacy `write` subcommand (one SSH round trip per fact).
|
|
344
|
+
|
|
345
|
+
Normalizes the result into the same {success_ids, failed} shape the
|
|
346
|
+
native `batch` path returns, so callers (sync_offline_facts) don't need
|
|
347
|
+
to know or care which protocol was actually used.
|
|
348
|
+
"""
|
|
349
|
+
success_ids: list[str] = []
|
|
350
|
+
failed: list[dict[str, Any]] = []
|
|
351
|
+
for fact in valid_facts:
|
|
352
|
+
single = _write_single_via_legacy_protocol(fact)
|
|
353
|
+
if single.ok:
|
|
354
|
+
success_ids.append(fact["fact_id"])
|
|
355
|
+
else:
|
|
356
|
+
failed.append({"fact_id": fact["fact_id"], "error": single.error or "legacy write failed"})
|
|
357
|
+
return WriteResult(
|
|
358
|
+
ok=True,
|
|
359
|
+
stdout=f"Legacy single-write fallback: {len(success_ids)} ok, {len(failed)} failed.",
|
|
360
|
+
results={"success_ids": success_ids, "failed": failed},
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def write_batch_remote(facts: list[dict[str, Any]]) -> WriteResult:
|
|
365
|
+
"""Write a batch of durable facts on the remote host using base64 transport.
|
|
366
|
+
|
|
367
|
+
Backward compatible with an already-deployed remote writer that only
|
|
368
|
+
understands single writes (pre-`batch` protocol): if the native batch
|
|
369
|
+
call fails because the remote doesn't recognize the `batch` subcommand,
|
|
370
|
+
transparently falls back to one legacy `write` call per fact so a new
|
|
371
|
+
client keeps working against an old, un-upgraded remote.
|
|
372
|
+
"""
|
|
373
|
+
if not settings.remote_enabled:
|
|
374
|
+
return WriteResult(ok=False, error=remote_not_configured_error())
|
|
375
|
+
|
|
376
|
+
if not facts:
|
|
377
|
+
return WriteResult(ok=True, stdout="No facts to write.")
|
|
378
|
+
|
|
379
|
+
# Validate all facts before sending
|
|
380
|
+
valid_facts = []
|
|
381
|
+
for fact in facts:
|
|
382
|
+
try:
|
|
383
|
+
agent = validate_agent(fact.get("agent", ""))
|
|
384
|
+
entity = validate_entity(fact.get("entity", ""))
|
|
385
|
+
attribute = validate_attribute(fact.get("attribute", ""))
|
|
386
|
+
source = validate_source(fact.get("source", ""))
|
|
387
|
+
fid = validate_fact_id(fact.get("fact_id", ""))
|
|
388
|
+
text = validate_fact_text(fact.get("text", ""))
|
|
389
|
+
conf = float(fact.get("confidence", 1.0))
|
|
390
|
+
if not 0.0 <= conf <= 1.0:
|
|
391
|
+
continue # Skip invalid
|
|
392
|
+
valid_facts.append({
|
|
393
|
+
"fact_id": fid,
|
|
394
|
+
"timestamp": fact.get("timestamp", ""),
|
|
395
|
+
"agent": agent,
|
|
396
|
+
"entity": entity,
|
|
397
|
+
"attribute": attribute,
|
|
398
|
+
"text": text,
|
|
399
|
+
"source": source,
|
|
400
|
+
"confidence": conf
|
|
401
|
+
})
|
|
402
|
+
except ValueError:
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
if not valid_facts:
|
|
406
|
+
return WriteResult(ok=False, error="No valid facts to write in batch.")
|
|
407
|
+
|
|
408
|
+
result = _write_batch_native(valid_facts)
|
|
409
|
+
if not result.ok and _looks_like_unsupported_batch_subcommand(result):
|
|
410
|
+
return _write_batch_via_legacy_single_writes(valid_facts)
|
|
411
|
+
return result
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def write_fact_remote(
|
|
415
|
+
*,
|
|
416
|
+
fact_id: str,
|
|
417
|
+
agent: str,
|
|
418
|
+
entity: str,
|
|
419
|
+
attribute: str,
|
|
420
|
+
text: str,
|
|
421
|
+
source: str,
|
|
422
|
+
confidence: float,
|
|
423
|
+
) -> WriteResult:
|
|
424
|
+
"""Write one durable fact on the remote host."""
|
|
425
|
+
validate_agent(agent)
|
|
426
|
+
validate_entity(entity)
|
|
427
|
+
validate_attribute(attribute)
|
|
428
|
+
validate_source(source)
|
|
429
|
+
validate_fact_id(fact_id)
|
|
430
|
+
validate_fact_text(text)
|
|
431
|
+
conf = float(confidence)
|
|
432
|
+
if not 0.0 <= conf <= 1.0:
|
|
433
|
+
raise ValueError("confidence must be between 0.0 and 1.0")
|
|
434
|
+
|
|
435
|
+
fact = {
|
|
436
|
+
"fact_id": fact_id,
|
|
437
|
+
"agent": agent,
|
|
438
|
+
"entity": entity,
|
|
439
|
+
"attribute": attribute,
|
|
440
|
+
"text": text,
|
|
441
|
+
"source": source,
|
|
442
|
+
"confidence": conf
|
|
443
|
+
}
|
|
444
|
+
return write_batch_remote([fact])
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def consolidate_remote() -> WriteResult:
|
|
448
|
+
if not settings.remote_enabled:
|
|
449
|
+
return WriteResult(ok=False, error=remote_not_configured_error())
|
|
450
|
+
|
|
451
|
+
script = f"""set -euo pipefail
|
|
452
|
+
cd {shlex.quote(settings.remote_root)}
|
|
453
|
+
{_maybe_source_env()}
|
|
454
|
+
python3 {shlex.quote(settings.remote_consolidate_script)}
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
res = _ssh_script(script, timeout=120)
|
|
458
|
+
except subprocess.TimeoutExpired:
|
|
459
|
+
return WriteResult(ok=False, error="SSH consolidate timed out")
|
|
460
|
+
except OSError as exc:
|
|
461
|
+
return WriteResult(ok=False, error=_sanitize_error(f"SSH consolidate failed: {exc}"))
|
|
462
|
+
|
|
463
|
+
if res.returncode != 0:
|
|
464
|
+
err = (res.stderr or res.stdout or "unknown remote error").strip()
|
|
465
|
+
return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=_sanitize_error(err))
|
|
466
|
+
return WriteResult(ok=True, stdout=res.stdout.strip(), stderr=res.stderr)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def pull_compiled_truth() -> WriteResult:
|
|
470
|
+
"""Pull remote compiled-truth directory without shell globs (Windows-safe)."""
|
|
471
|
+
if not settings.remote_enabled:
|
|
472
|
+
return WriteResult(ok=False, error=remote_not_configured_error())
|
|
473
|
+
|
|
474
|
+
settings.ensure_dirs()
|
|
475
|
+
staging_dir = settings.home / "staging-truth"
|
|
476
|
+
if staging_dir.exists():
|
|
477
|
+
import shutil
|
|
478
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
479
|
+
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
480
|
+
|
|
481
|
+
truth = settings.remote_truth_subdir.strip("/")
|
|
482
|
+
remote = f"{settings.ssh_host}:{settings.remote_root}/{truth}/."
|
|
483
|
+
dest = str(staging_dir)
|
|
484
|
+
try:
|
|
485
|
+
res = _run(
|
|
486
|
+
[
|
|
487
|
+
"scp",
|
|
488
|
+
"-o",
|
|
489
|
+
"BatchMode=yes",
|
|
490
|
+
"-o",
|
|
491
|
+
f"ConnectTimeout={settings.ssh_connect_timeout}",
|
|
492
|
+
"-r",
|
|
493
|
+
remote,
|
|
494
|
+
dest,
|
|
495
|
+
],
|
|
496
|
+
timeout=120,
|
|
497
|
+
check=False,
|
|
498
|
+
)
|
|
499
|
+
except subprocess.TimeoutExpired:
|
|
500
|
+
return WriteResult(ok=False, error="SCP pull timed out")
|
|
501
|
+
except OSError as exc:
|
|
502
|
+
return WriteResult(ok=False, error=_sanitize_error(f"SCP pull failed: {exc}"))
|
|
503
|
+
|
|
504
|
+
if res.returncode != 0:
|
|
505
|
+
err = (res.stderr or res.stdout or "scp failed").strip()
|
|
506
|
+
return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=_sanitize_error(err))
|
|
507
|
+
|
|
508
|
+
from mindsync.storage import publish_compiled_truth
|
|
509
|
+
try:
|
|
510
|
+
publish_compiled_truth(staging_dir)
|
|
511
|
+
except Exception as exc:
|
|
512
|
+
return WriteResult(ok=False, error=_sanitize_error(f"Failed to publish truth: {exc}"))
|
|
513
|
+
|
|
514
|
+
import shutil
|
|
515
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
516
|
+
return WriteResult(ok=True)
|
mindsync/bus/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""AgentRelay Event Bus module for MindSync."""
|
|
2
|
+
|
|
3
|
+
from mindsync.bus.models import Event, EventType, Identity, Message, PolicyRule
|
|
4
|
+
from mindsync.bus.events import EventBus, poll_events, publish_event, subscribe
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Event",
|
|
8
|
+
"EventType",
|
|
9
|
+
"Identity",
|
|
10
|
+
"Message",
|
|
11
|
+
"PolicyRule",
|
|
12
|
+
"EventBus",
|
|
13
|
+
"publish_event",
|
|
14
|
+
"poll_events",
|
|
15
|
+
"subscribe",
|
|
16
|
+
]
|