@melaya/runner 1.1.61 → 1.1.62

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/localVault.py ADDED
@@ -0,0 +1,745 @@
1
+ """
2
+ localVault.py — the runner's long-lived Obsidian vault host.
3
+ =============================================================
4
+ Started by the runner (src/vaults.ts, VaultHostProcess) with the venv python
5
+ and PYTHONPATH = the shared bundle, so the pure-Python engine
6
+ ``shared.runtime.vault.engine.Vault`` is importable. One process serves every
7
+ vault registered on this computer.
8
+
9
+ Protocol: JSON lines on stdin / stdout.
10
+
11
+ request {"id": "v1", "cmd": "<cmd>", ...}
12
+ response {"id": "v1", "ok": true, "result": ...} | {"id": "v1", "ok": false, "error": "<code>"}
13
+ event {"event": "status", "vaultId", "status", "notes", "bytes", "indexRev", "error"?}
14
+ {"event": "ready"}
15
+
16
+ Commands:
17
+ register {vault: {id, root, configDirName, include, exclude, indexPath}, cursorKey}
18
+ → {notes, bytes, indexRev, capabilities} (opens + incremental scan)
19
+ unregister {vaultId}
20
+ op {vaultId, op, args, folders, write, idempotency_key} → the engine envelope
21
+ changed {vaultId, paths | null} (debounced fs.watch batch from Node; null = full)
22
+ mirror_sync {vaultId, token, apiBase, budgetSeconds} → one cloud-copy sync round
23
+ (localVaultMirror.py; storage: mirror vaults only)
24
+ mirror_drop {vaultId} (mirroring turned off: forget the ledger)
25
+ ping
26
+
27
+ Authority is checked by the Node side BEFORE an op reaches this process
28
+ (capability, grant, read/write, route, revocation); this host enforces the
29
+ remaining boundary:
30
+ - only public methods defined on the engine's Vault class can be called,
31
+ never ``apply`` / ``resume_recovery`` / ``refresh`` / lifecycle methods;
32
+ - the grant's ``folders`` always reach the engine, the caller's never (an op
33
+ that cannot be folder-scoped is refused when the grant is folder-bounded);
34
+ - a write method only proposes; the host applies THAT proposal in the same
35
+ request (``args.dry_run`` returns the proposal instead), so a caller can
36
+ never hand the engine forged bytes to apply. ``idempotency_key`` reaches
37
+ the write method, and an applied result is cached per key: a retry
38
+ returns it and never re-applies.
39
+
40
+ Change detection: Node feeds fs.watch batches (``changed``); a background
41
+ thread runs a stat-only reconciliation per vault every 10 minutes (plan §6.4),
42
+ which also covers watcher overflow, sleep and platforms without recursive
43
+ fs.watch. Scans of one vault are serialised and coalesced.
44
+
45
+ Never written to stdout/stderr: note content, op arguments, capability tokens.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import importlib
50
+ import importlib.util
51
+ import inspect
52
+ import json
53
+ import os
54
+ import sqlite3
55
+ import sys
56
+ import threading
57
+ import time
58
+ from collections import OrderedDict
59
+ from concurrent.futures import ThreadPoolExecutor
60
+ from contextlib import contextmanager
61
+ from typing import Any
62
+
63
+ RECONCILE_SECONDS = 600
64
+ RECONCILE_TICK = 30
65
+ # Engine methods a caller may never name. ``apply`` / ``resume_recovery``
66
+ # take a caller-supplied proposal (a sandbox could write forged bytes); the
67
+ # others are host lifecycle. Writes are applied by the host itself (cmd_op).
68
+ _HOST_ONLY = frozenset({"apply", "resume_recovery", "refresh", "close", "capabilities_list", "shutdown"})
69
+ # Engine write methods: each returns a proposal, which the host applies in the
70
+ # same request (unless args.dry_run). Mirror of VAULT_WRITE_OPS in src/vaults.ts.
71
+ WRITE_OPS = frozenset({
72
+ "create", "append", "prepend", "patch", "property_set", "property_remove", "move", "rename", "copy",
73
+ "delete", "task", "daily_append", "daily_prepend", "unique", "template_insert", "bookmark",
74
+ "canvas_write", "base_create", "upload",
75
+ })
76
+ IDEMPOTENCY_CACHE_MAX = 512
77
+ # Engine entry points, tried in order (engine.Vault: ``refresh(changed_paths)``
78
+ # and ``vault_info()``); older or newer facades may name them differently.
79
+ _SCAN_METHODS = ("refresh", "scan", "reindex", "sync")
80
+ _STATS_METHODS = ("vault_info", "stats", "info")
81
+
82
+ _out_lock = threading.Lock()
83
+
84
+
85
+ def _send(obj: dict) -> None:
86
+ line = json.dumps(obj, ensure_ascii=False, default=str)
87
+ with _out_lock:
88
+ sys.stdout.write(line + "\n")
89
+ sys.stdout.flush()
90
+
91
+
92
+ def _log(msg: str) -> None:
93
+ # Metadata only (ids, counts, exception types) — never content.
94
+ try:
95
+ sys.stderr.write(f"[localVault] {msg}\n")
96
+ sys.stderr.flush()
97
+ except Exception:
98
+ pass
99
+
100
+
101
+ # ── engine ──────────────────────────────────────────────────────────────
102
+
103
+ _engine_cls: Any = None
104
+ _engine_error = ""
105
+ _envelope: Any = None
106
+
107
+
108
+ _ISOLATED_PKG = "_melaya_vault_engine"
109
+
110
+
111
+ def _import_isolated() -> tuple[Any, Any]:
112
+ """Load shared/runtime/vault as a standalone package.
113
+
114
+ The engine only uses relative imports, so it does not need ``shared`` or
115
+ ``shared.runtime``, whose ``__init__`` modules import the whole agent stack
116
+ (agentscope, every tool module: tens of seconds on a cold start). The vault
117
+ host needs none of that."""
118
+ for base in sys.path:
119
+ d = os.path.join(base or ".", "shared", "runtime", "vault")
120
+ init = os.path.join(d, "__init__.py")
121
+ if not (os.path.isfile(init) and os.path.isfile(os.path.join(d, "engine.py"))):
122
+ continue
123
+ spec = importlib.util.spec_from_file_location(_ISOLATED_PKG, init, submodule_search_locations=[d])
124
+ if spec is None or spec.loader is None:
125
+ continue
126
+ mod = importlib.util.module_from_spec(spec)
127
+ sys.modules[_ISOLATED_PKG] = mod
128
+ try:
129
+ spec.loader.exec_module(mod)
130
+ eng_mod = importlib.import_module(f"{_ISOLATED_PKG}.engine")
131
+ except Exception:
132
+ for k in [k for k in sys.modules if k == _ISOLATED_PKG or k.startswith(_ISOLATED_PKG + ".")]:
133
+ sys.modules.pop(k, None)
134
+ raise
135
+ try:
136
+ env_mod = importlib.import_module(f"{_ISOLATED_PKG}.envelope")
137
+ except Exception:
138
+ env_mod = None # the host builds plain envelopes itself
139
+ return eng_mod, env_mod
140
+ raise ImportError("shared/runtime/vault not found on PYTHONPATH")
141
+
142
+
143
+ def _load_engine() -> Any:
144
+ global _engine_cls, _engine_error, _envelope
145
+ if _engine_cls is not None or _engine_error:
146
+ return _engine_cls
147
+ try:
148
+ eng_mod, env_mod = _import_isolated()
149
+ except Exception:
150
+ try: # regular package import (slower: runs shared/__init__)
151
+ from shared.runtime.vault import engine as eng_mod # type: ignore
152
+ try:
153
+ from shared.runtime.vault import envelope as env_mod # type: ignore
154
+ except Exception:
155
+ env_mod = None
156
+ except Exception as e: # missing bundle / older bundle
157
+ _engine_error = f"{type(e).__name__}"
158
+ _log(f"vault engine unavailable: {_engine_error}")
159
+ return None
160
+ _envelope = env_mod
161
+ _engine_cls = getattr(eng_mod, "Vault", None)
162
+ if _engine_cls is None:
163
+ _engine_error = "no Vault class"
164
+ _log("vault engine unavailable: no Vault class")
165
+ return _engine_cls
166
+
167
+
168
+ def _err(code: str, message: str) -> dict:
169
+ if _envelope is not None:
170
+ try:
171
+ return _envelope.err(code, message)
172
+ except Exception:
173
+ pass
174
+ return {"ok": False, "error": {"code": code, "message": message, "retry_after": None}}
175
+
176
+
177
+ def _ok(vault_id: str, content: Any) -> dict:
178
+ if _envelope is not None:
179
+ try:
180
+ return _envelope.ok(vault_id, content=content)
181
+ except Exception:
182
+ pass
183
+ return {"ok": True, "vault_id": vault_id, "content": content}
184
+
185
+
186
+ def engine_ops(cls: Any) -> list[str]:
187
+ """Public methods DEFINED on the Vault class (and its own bases, not object)."""
188
+ names: set[str] = set()
189
+ for klass in getattr(cls, "__mro__", (cls,)):
190
+ if klass is object:
191
+ continue
192
+ for name, member in vars(klass).items():
193
+ if name.startswith("_") or name in _HOST_ONLY:
194
+ continue
195
+ fn = member.__func__ if isinstance(member, (staticmethod, classmethod)) else member
196
+ if inspect.isfunction(fn):
197
+ names.add(name)
198
+ return sorted(names)
199
+
200
+
201
+ def _params(fn: Any) -> tuple[set[str], bool]:
202
+ try:
203
+ sig = inspect.signature(fn)
204
+ except (TypeError, ValueError):
205
+ return set(), True
206
+ names = {p.name for p in sig.parameters.values()
207
+ if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)}
208
+ var_kw = any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values())
209
+ return names, var_kw
210
+
211
+
212
+ def _call_filtered(fn: Any, **kwargs: Any) -> Any:
213
+ """Call with only the keyword arguments the callable accepts."""
214
+ names, var_kw = _params(fn)
215
+ return fn(**(kwargs if var_kw else {k: v for k, v in kwargs.items() if k in names}))
216
+
217
+
218
+ # ── vaults ──────────────────────────────────────────────────────────────
219
+
220
+ class HostedVault:
221
+ def __init__(self, vid: str, root: str, index_path: str, engine: Any):
222
+ self.id = vid
223
+ self.root = root
224
+ self.index_path = index_path
225
+ self.engine = engine
226
+ self.scan_lock = threading.Lock()
227
+ self.pending_lock = threading.Lock()
228
+ self.pending: set[str] | None = set() # None = a full scan is queued
229
+ self.queued = False
230
+ self.last_full = 0.0
231
+ self.last_status: tuple | None = None
232
+ self.closed = False
233
+ # Every engine call is counted, so close() waits for in-flight scans,
234
+ # stats and ops instead of closing SQLite under them.
235
+ self.cv = threading.Condition()
236
+ self.inflight = 0
237
+ # Single writer per vault, plus applied results by idempotency key:
238
+ # a retried write returns the stored result and never re-applies.
239
+ self.write_lock = threading.Lock()
240
+ self.applied: OrderedDict = OrderedDict()
241
+
242
+
243
+ class VaultClosed(Exception):
244
+ pass
245
+
246
+
247
+ @contextmanager
248
+ def _using(hv: HostedVault):
249
+ with hv.cv:
250
+ if hv.closed:
251
+ raise VaultClosed()
252
+ hv.inflight += 1
253
+ try:
254
+ yield hv.engine
255
+ finally:
256
+ with hv.cv:
257
+ hv.inflight -= 1
258
+ hv.cv.notify_all()
259
+
260
+
261
+ _vaults: dict[str, HostedVault] = {}
262
+ _vaults_lock = threading.Lock()
263
+ _req_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="vault-req")
264
+ _scan_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="vault-scan")
265
+
266
+
267
+ class ScanFailed(Exception):
268
+ """The engine answered a scan with an error envelope (code only, no content)."""
269
+
270
+
271
+ def _scan_now(hv: HostedVault, paths: list[str] | None) -> None:
272
+ """One incremental scan through the engine; serialised per vault."""
273
+ with hv.scan_lock, _using(hv) as eng:
274
+ for name in _SCAN_METHODS:
275
+ fn = getattr(eng, name, None)
276
+ if not callable(fn):
277
+ continue
278
+ names, var_kw = _params(fn)
279
+ kwargs: dict[str, Any] = {}
280
+ if paths is not None:
281
+ if "changed_paths" in names or var_kw:
282
+ kwargs["changed_paths"] = paths
283
+ elif "paths" in names:
284
+ kwargs["paths"] = paths
285
+ # else: this method only does full scans
286
+ r = fn(**kwargs)
287
+ if isinstance(r, dict) and r.get("ok") is False:
288
+ code = (r.get("error") or {}).get("code") if isinstance(r.get("error"), dict) else None
289
+ raise ScanFailed(str(code or "invalid"))
290
+ break
291
+ if paths is None:
292
+ hv.last_full = time.time()
293
+
294
+
295
+ def _stats(hv: HostedVault) -> dict:
296
+ """notes / bytes / indexRev, from the engine when it reports them, else
297
+ from the index database (read-only), else zeros."""
298
+ eng = hv.engine
299
+ for name in _STATS_METHODS:
300
+ fn = getattr(eng, name, None)
301
+ if not callable(fn):
302
+ continue
303
+ try:
304
+ r = _call_filtered(fn)
305
+ except Exception:
306
+ continue
307
+ if isinstance(r, dict) and r.get("ok") is False:
308
+ continue
309
+ d = r.get("content") if isinstance(r, dict) and isinstance(r.get("content"), dict) else r
310
+ if isinstance(d, dict) and ("notes" in d or "notes_count" in d):
311
+ rev = r.get("index_rev") if isinstance(r, dict) and r.get("index_rev") is not None else d.get("index_rev", 0)
312
+ return {
313
+ "notes": int(d.get("notes", d.get("notes_count", 0)) or 0),
314
+ "bytes": int(d.get("size", d.get("bytes", 0)) or 0),
315
+ "indexRev": int(rev or 0),
316
+ }
317
+ try:
318
+ con = sqlite3.connect(f"file:{hv.index_path}?mode=ro", uri=True, timeout=5)
319
+ try:
320
+ notes = con.execute("SELECT COUNT(*) FROM notes").fetchone()[0]
321
+ size = con.execute("SELECT COALESCE(SUM(size), 0) FROM manifest").fetchone()[0]
322
+ row = con.execute("SELECT value FROM meta WHERE key='index_rev'").fetchone()
323
+ return {"notes": int(notes or 0), "bytes": int(size or 0), "indexRev": int(row[0]) if row else 0}
324
+ finally:
325
+ con.close()
326
+ except Exception:
327
+ return {"notes": 0, "bytes": 0, "indexRev": 0}
328
+
329
+
330
+ def _emit_status(hv: HostedVault, status: str = "ready", error: str | None = None, force: bool = False) -> dict:
331
+ st: dict = {}
332
+ if status == "ready":
333
+ try:
334
+ with _using(hv):
335
+ st = _stats(hv)
336
+ except VaultClosed:
337
+ return {}
338
+ key = (status, st.get("notes"), st.get("bytes"), st.get("indexRev"), error)
339
+ if force or key != hv.last_status:
340
+ hv.last_status = key
341
+ ev = {"event": "status", "vaultId": hv.id, "status": status, **st}
342
+ if error:
343
+ ev["error"] = error[:300]
344
+ _send(ev)
345
+ return st
346
+
347
+
348
+ def _queue_scan(hv: HostedVault, paths: list[str] | None) -> None:
349
+ """Coalesce into the vault's pending set; one drain worker per vault."""
350
+ with hv.pending_lock:
351
+ if paths is None or hv.pending is None:
352
+ hv.pending = None
353
+ else:
354
+ hv.pending.update(p for p in paths if isinstance(p, str) and p)
355
+ if len(hv.pending) > 5000:
356
+ hv.pending = None
357
+ if hv.queued:
358
+ return
359
+ hv.queued = True
360
+ _scan_pool.submit(_drain, hv)
361
+
362
+
363
+ def _drain(hv: HostedVault) -> None:
364
+ while True:
365
+ with hv.pending_lock:
366
+ batch = hv.pending
367
+ if batch is not None and not batch:
368
+ hv.queued = False
369
+ return
370
+ hv.pending = set()
371
+ if hv.closed:
372
+ with hv.pending_lock:
373
+ hv.queued = False
374
+ return
375
+ try:
376
+ _scan_now(hv, None if batch is None else sorted(batch))
377
+ _emit_status(hv)
378
+ except VaultClosed:
379
+ continue
380
+ except Exception as e:
381
+ why = str(e) if isinstance(e, ScanFailed) else type(e).__name__
382
+ _log(f"scan failed for {hv.id}: {why}")
383
+ _emit_status(hv, "error", f"index refresh failed ({why})")
384
+
385
+
386
+ def _reconcile_loop() -> None:
387
+ while True:
388
+ time.sleep(RECONCILE_TICK)
389
+ now = time.time()
390
+ with _vaults_lock:
391
+ due = [hv for hv in _vaults.values() if now - hv.last_full >= RECONCILE_SECONDS]
392
+ for hv in due:
393
+ hv.last_full = now # do not re-queue while the scan runs
394
+ _queue_scan(hv, None)
395
+
396
+
397
+ def _capabilities(eng: Any) -> dict:
398
+ """Capability report stored by the server (agents.vaults.capabilities)."""
399
+ caps: dict[str, Any] = {"ops": engine_ops(type(eng)), "source": "device", "app_bridge": False}
400
+ try:
401
+ lst = getattr(eng, "capabilities_list", None)
402
+ if callable(lst):
403
+ caps["features"] = list(lst())
404
+ rep = getattr(eng, "capabilities", None)
405
+ val = rep() if callable(rep) else None
406
+ if isinstance(val, dict) and val.get("ok") is not False:
407
+ if isinstance(val.get("report"), dict):
408
+ caps["report"] = val["report"]
409
+ if "features" not in caps and isinstance(val.get("items"), list):
410
+ caps["features"] = list(val["items"])
411
+ except Exception:
412
+ pass
413
+ return caps
414
+
415
+
416
+ def _close(hv: HostedVault) -> None:
417
+ with hv.cv:
418
+ hv.closed = True
419
+ hv.cv.wait_for(lambda: hv.inflight == 0, timeout=120)
420
+ with hv.scan_lock:
421
+ fn = getattr(hv.engine, "close", None)
422
+ if callable(fn):
423
+ try:
424
+ fn()
425
+ except Exception:
426
+ pass
427
+
428
+
429
+ # ── commands ────────────────────────────────────────────────────────────
430
+
431
+ class HostError(Exception):
432
+ """An RPC-level failure (the Node side maps it); ``code`` is short and fixed."""
433
+
434
+ def __init__(self, code: str):
435
+ super().__init__(code)
436
+ self.code = code
437
+
438
+
439
+ def cmd_register(msg: dict) -> dict:
440
+ cls = _load_engine()
441
+ if cls is None:
442
+ raise HostError("vault engine unavailable in this runner's runtime; update the runner")
443
+ v = msg.get("vault") or {}
444
+ vid = str(v.get("id") or "")
445
+ root = str(v.get("root") or "")
446
+ index_path = str(v.get("indexPath") or "")
447
+ if not vid.startswith("vlt_") or not root or not index_path:
448
+ raise HostError("invalid")
449
+ if not os.path.isdir(root):
450
+ raise HostError("not_a_folder")
451
+ os.makedirs(os.path.dirname(index_path), exist_ok=True)
452
+ key_hex = str(msg.get("cursorKey") or "")
453
+ try:
454
+ cursor_key = bytes.fromhex(key_hex) if key_hex else os.urandom(32)
455
+ except ValueError:
456
+ cursor_key = os.urandom(32)
457
+ with _vaults_lock:
458
+ old = _vaults.pop(vid, None)
459
+ if old is not None:
460
+ _close(old)
461
+ eng = _call_filtered(
462
+ cls,
463
+ root=root,
464
+ vault_id=vid,
465
+ label=str(v.get("label") or vid),
466
+ index_path=index_path,
467
+ config_dir_name=str(v.get("configDirName") or ".obsidian"),
468
+ include_globs=[g for g in (v.get("include") or []) if isinstance(g, str)],
469
+ exclude_globs=[g for g in (v.get("exclude") or []) if isinstance(g, str)],
470
+ cursor_key=cursor_key,
471
+ )
472
+ hv = HostedVault(vid, root, index_path, eng)
473
+ _send({"event": "status", "vaultId": vid, "status": "indexing"})
474
+ try:
475
+ _scan_now(hv, None)
476
+ except Exception as e:
477
+ _close(hv)
478
+ why = str(e) if isinstance(e, ScanFailed) else type(e).__name__
479
+ _log(f"initial index failed for {vid}: {why}")
480
+ raise HostError(f"indexing failed ({why})")
481
+ with _vaults_lock:
482
+ _vaults[vid] = hv
483
+ st = _emit_status(hv, force=True)
484
+ return {**st, "capabilities": _capabilities(eng)}
485
+
486
+
487
+ def cmd_unregister(msg: dict) -> dict:
488
+ vid = str(msg.get("vaultId") or "")
489
+ with _vaults_lock:
490
+ hv = _vaults.pop(vid, None)
491
+ _drop_mirror(vid, delete=False)
492
+ if hv is not None:
493
+ _close(hv)
494
+ return {"removed": hv is not None}
495
+
496
+
497
+ # ── cloud copy (localVaultMirror.py, plan §6.4 S4) ──────────────────────
498
+ # One Mirror per vault, one sync at a time per vault. The capability arrives
499
+ # with each request from Node (memory only) and is never written to disk.
500
+
501
+ _mirror_mod: Any = None
502
+ _mirrors: dict[str, tuple[Any, threading.Lock]] = {}
503
+ _mirrors_lock = threading.Lock()
504
+
505
+
506
+ def _mirror_module() -> Any:
507
+ global _mirror_mod
508
+ if _mirror_mod is None:
509
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "localVaultMirror.py")
510
+ spec = importlib.util.spec_from_file_location("_melaya_vault_mirror", path)
511
+ if spec is None or spec.loader is None:
512
+ raise HostError("mirror module missing; update the runner")
513
+ mod = importlib.util.module_from_spec(spec)
514
+ spec.loader.exec_module(mod)
515
+ _mirror_mod = mod
516
+ return _mirror_mod
517
+
518
+
519
+ def _drop_mirror(vid: str, delete: bool) -> None:
520
+ with _mirrors_lock:
521
+ entry = _mirrors.pop(vid, None)
522
+ if entry is not None:
523
+ with entry[1]:
524
+ entry[0].close()
525
+ if delete:
526
+ with _vaults_lock:
527
+ hv = _vaults.get(vid)
528
+ base = os.path.join(os.path.dirname(hv.index_path), "mirror.sqlite") if hv else None
529
+ for suffix in ("", "-wal", "-shm"):
530
+ if base:
531
+ try:
532
+ os.remove(base + suffix)
533
+ except FileNotFoundError:
534
+ pass
535
+
536
+
537
+ def cmd_mirror_sync(msg: dict) -> dict:
538
+ """One sync round: {vaultId, token, apiBase, budgetSeconds} → status."""
539
+ vid = str(msg.get("vaultId") or "")
540
+ with _vaults_lock:
541
+ hv = _vaults.get(vid)
542
+ if hv is None:
543
+ raise HostError("unknown_vault")
544
+ token = str(msg.get("token") or "")
545
+ base = str(msg.get("apiBase") or "")
546
+ if not token or not base.startswith(("https://", "http://")):
547
+ raise HostError("invalid")
548
+ policy = getattr(hv.engine, "policy", None)
549
+ if policy is None:
550
+ return {"state": "error", "error": "this vault engine cannot mirror"}
551
+ mod = _mirror_module()
552
+ with _mirrors_lock:
553
+ entry = _mirrors.get(vid)
554
+ if entry is None:
555
+ ledger = os.path.join(os.path.dirname(hv.index_path), "mirror.sqlite")
556
+ entry = (mod.Mirror(vid, policy, hv.index_path, ledger, log=_log), threading.Lock())
557
+ _mirrors[vid] = entry
558
+ mirror, lock = entry
559
+ if not lock.acquire(blocking=False):
560
+ return {"state": "busy"} # coalesced: the running round picks the changes up next
561
+ try:
562
+ result = mirror.sync(mod.HttpTransport(base, token), budget_s=float(msg.get("budgetSeconds") or 600))
563
+ finally:
564
+ lock.release()
565
+ if result.get("rescan"):
566
+ _queue_scan(hv, [p for p in result["rescan"] if isinstance(p, str)])
567
+ return result
568
+
569
+
570
+ def cmd_mirror_drop(msg: dict) -> dict:
571
+ """Mirroring was turned off: forget the ledger (the copy is purged server-side)."""
572
+ _drop_mirror(str(msg.get("vaultId") or ""), delete=True)
573
+ return {"dropped": True}
574
+
575
+
576
+ def cmd_changed(msg: dict) -> dict:
577
+ vid = str(msg.get("vaultId") or "")
578
+ with _vaults_lock:
579
+ hv = _vaults.get(vid)
580
+ if hv is None:
581
+ raise HostError("unknown_vault")
582
+ paths = msg.get("paths")
583
+ _queue_scan(hv, None if not isinstance(paths, list) else [p for p in paths if isinstance(p, str)][:5000])
584
+ return {"queued": True}
585
+
586
+
587
+ def _engine_call(hv: HostedVault, fn: Any, op: str, args: dict) -> dict:
588
+ """Call one engine method; always an envelope."""
589
+ try:
590
+ with _using(hv):
591
+ result = fn(**args)
592
+ except VaultClosed:
593
+ raise HostError("unknown_vault")
594
+ except TypeError as e:
595
+ # Wrong / missing arguments from the model: the message names parameters only.
596
+ return _err("invalid", f"bad arguments for {op}: {str(e)[:200]}")
597
+ except Exception as e:
598
+ env = getattr(e, "envelope", None)
599
+ if callable(env):
600
+ try:
601
+ return env()
602
+ except Exception:
603
+ pass
604
+ _log(f"op {op} failed on {hv.id}: {type(e).__name__}")
605
+ return _err("invalid", f"{op} failed ({type(e).__name__})")
606
+ if isinstance(result, dict) and isinstance(result.get("ok"), bool):
607
+ return result
608
+ return _ok(hv.id, result)
609
+
610
+
611
+ def cmd_op(msg: dict) -> dict:
612
+ """Dispatch one engine op; always returns an envelope (never raises for
613
+ engine failures — only an unknown vault is an RPC error, so Node can
614
+ re-register it after a host restart).
615
+
616
+ Writes: the engine's write method only PROPOSES a change; the host applies
617
+ that exact proposal in the same request (``apply`` itself is never callable
618
+ by a caller), unless ``args.dry_run`` asks for the proposal only."""
619
+ vid = str(msg.get("vaultId") or "")
620
+ with _vaults_lock:
621
+ hv = _vaults.get(vid)
622
+ if hv is None:
623
+ raise HostError("unknown_vault")
624
+ cls = _load_engine()
625
+ op = str(msg.get("op") or "")
626
+ if cls is None:
627
+ return _err("unsupported", "The vault engine is not available in this runner's runtime.")
628
+ if op in _HOST_ONLY:
629
+ return _err("forbidden", f"{op[:64]} cannot be called directly.")
630
+ if op not in engine_ops(type(hv.engine)):
631
+ return _err("unsupported", f"Unknown vault operation: {op[:64]}")
632
+ fn = getattr(hv.engine, op, None)
633
+ if not callable(fn):
634
+ return _err("unsupported", f"Unknown vault operation: {op[:64]}")
635
+ raw = msg.get("args")
636
+ args = {k: v for k, v in (raw.items() if isinstance(raw, dict) else [])
637
+ if isinstance(k, str) and not k.startswith("_")}
638
+ # Folder bounds come from the grant only, never from the model's arguments.
639
+ args.pop("folders", None)
640
+ args.pop("idempotency_key", None)
641
+ dry_run = bool(args.pop("dry_run", False)) if op in WRITE_OPS else False
642
+ # An empty grant folder list means the whole vault, which the engine spells
643
+ # ``folders=None`` (an empty list there would match nothing).
644
+ folders = [f for f in (msg.get("folders") or []) if isinstance(f, str)] or None
645
+ names, var_kw = _params(fn)
646
+ if "folders" in names or var_kw:
647
+ args["folders"] = folders
648
+ elif folders:
649
+ return _err("unsupported", f"{op} cannot be limited to the folders this run may see.")
650
+ idem = msg.get("idempotency_key")
651
+ idem = idem[:200] if isinstance(idem, str) and idem else None
652
+ if idem and ("idempotency_key" in names or var_kw):
653
+ args["idempotency_key"] = idem
654
+
655
+ if op not in WRITE_OPS or dry_run:
656
+ return _engine_call(hv, fn, op, args)
657
+
658
+ apply_fn = getattr(hv.engine, "apply", None)
659
+ if not callable(apply_fn):
660
+ return _err("unsupported", "This vault engine cannot apply changes.")
661
+ key = (op, idem) if idem else None
662
+ with hv.write_lock:
663
+ if key is not None and key in hv.applied:
664
+ hv.applied.move_to_end(key)
665
+ return hv.applied[key]
666
+ proposed = _engine_call(hv, fn, op, args)
667
+ if proposed.get("ok") is not True:
668
+ return proposed
669
+ proposal = proposed.get("proposal")
670
+ if not isinstance(proposal, dict):
671
+ # Nothing to apply (e.g. a no-op edit): the engine's answer stands.
672
+ return proposed
673
+ a_names, a_var_kw = _params(apply_fn)
674
+ a_args: dict[str, Any] = {"proposal": proposal}
675
+ if "folders" in a_names or a_var_kw:
676
+ a_args["folders"] = folders
677
+ result = _engine_call(hv, apply_fn, "apply", a_args)
678
+ if key is not None and result.get("ok") is True:
679
+ hv.applied[key] = result
680
+ while len(hv.applied) > IDEMPOTENCY_CACHE_MAX:
681
+ hv.applied.popitem(last=False)
682
+ if result.get("ok") is True and isinstance(result.get("path"), str):
683
+ _queue_scan(hv, [result["path"]]) # fresh counts even without a watcher
684
+ return result
685
+
686
+
687
+ _COMMANDS = {
688
+ "register": cmd_register,
689
+ "unregister": cmd_unregister,
690
+ "changed": cmd_changed,
691
+ "op": cmd_op,
692
+ "mirror_sync": cmd_mirror_sync,
693
+ "mirror_drop": cmd_mirror_drop,
694
+ "ping": lambda _m: {"pong": True, "engine": _load_engine() is not None},
695
+ }
696
+
697
+
698
+ def _handle(msg: dict) -> None:
699
+ rid = msg.get("id")
700
+ fn = _COMMANDS.get(str(msg.get("cmd") or ""))
701
+ if fn is None:
702
+ _send({"id": rid, "ok": False, "error": "unknown_command"})
703
+ return
704
+ try:
705
+ _send({"id": rid, "ok": True, "result": fn(msg)})
706
+ except HostError as e:
707
+ _send({"id": rid, "ok": False, "error": e.code})
708
+ except Exception as e:
709
+ _log(f"{msg.get('cmd')} failed: {type(e).__name__}")
710
+ _send({"id": rid, "ok": False, "error": f"host_error ({type(e).__name__})"})
711
+
712
+
713
+ def main() -> int:
714
+ try:
715
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
716
+ sys.stdin.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
717
+ except Exception:
718
+ pass
719
+ threading.Thread(target=_reconcile_loop, name="vault-reconcile", daemon=True).start()
720
+ _send({"event": "ready"})
721
+ for line in sys.stdin:
722
+ line = line.strip()
723
+ if not line:
724
+ continue
725
+ try:
726
+ msg = json.loads(line)
727
+ except json.JSONDecodeError:
728
+ continue
729
+ if not isinstance(msg, dict):
730
+ continue
731
+ if msg.get("cmd") == "ping":
732
+ _handle(msg)
733
+ else:
734
+ _req_pool.submit(_handle, msg)
735
+ # stdin closed: the runner is gone. Close indexes (checkpoints the WAL).
736
+ with _vaults_lock:
737
+ hosted = list(_vaults.values())
738
+ _vaults.clear()
739
+ for hv in hosted:
740
+ _close(hv)
741
+ return 0
742
+
743
+
744
+ if __name__ == "__main__":
745
+ sys.exit(main())