@melaya/runner 1.1.60 → 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/dist/assistantFiles.d.ts +29 -0
- package/dist/assistantFiles.js +113 -0
- package/dist/assistantHost.py +333 -37
- package/dist/cli.js +7 -0
- package/dist/connection.js +236 -65
- package/dist/folderPicker.d.ts +15 -0
- package/dist/folderPicker.js +69 -0
- package/dist/localRelay.d.ts +8 -1
- package/dist/localRelay.js +21 -1
- package/dist/localVault.py +745 -0
- package/dist/localVaultMirror.py +507 -0
- package/dist/obsidianApp.d.ts +324 -0
- package/dist/obsidianApp.js +1143 -0
- package/dist/obsidianAppCli.d.ts +15 -0
- package/dist/obsidianAppCli.js +123 -0
- package/dist/sharedVendor.js +33 -1
- package/dist/vaultMirror.d.ts +104 -0
- package/dist/vaultMirror.js +305 -0
- package/dist/vaults.d.ts +350 -0
- package/dist/vaults.js +1130 -0
- package/localVault.py +745 -0
- package/localVaultMirror.py +507 -0
- package/package.json +4 -2
|
@@ -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())
|