-
-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathsync.py
More file actions
189 lines (170 loc) · 8.88 KB
/
Copy pathsync.py
File metadata and controls
189 lines (170 loc) · 8.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
#!/usr/bin/env python3
"""Cloud sync as a local command — your machine, your folder, your keys.
Point two or more devices at one shared folder (Dropbox / iCloud / OneDrive /
Syncthing / a network drive / a git repo) and sync your Engraphis memory store
across all of them, with deterministic conflict resolution — no "conflicted copy"
files, no lost notes. Examples::
# Preview what a sync would change (recommended first run — never writes)
python -m scripts.sync --db engraphis.db --workspace acme --remote "D:/Dropbox/engraphis" --dry-run
# Sync for real: publish this device's snapshot, pull + merge every other device's
python -m scripts.sync --db engraphis.db --workspace acme --remote "D:/Dropbox/engraphis"
Schedule it (cron):: */15 * * * * cd /path/to/repo && python -m scripts.sync --db engraphis.db --workspace acme --remote ~/Dropbox/engraphis
Schedule it (Windows):: schtasks /Create /SC MINUTE /MO 15 /TN EngraphisSync /TR "python -m scripts.sync --db C:\\path\\engraphis.db --workspace acme --remote C:\\Users\\me\\Dropbox\\engraphis"
Shared-folder sync is a Pro feature and is checked locally. Managed-relay sync uses a
scoped, expiring per-user token and is authorized by the customer server. The core engine
in ``engraphis/core/sync.py`` remains transport- and license-agnostic.
"""
from __future__ import annotations
import argparse
import json
import sys
from engraphis.core.engine import MemoryEngine
from engraphis.core.sync import SyncEngine
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Sync an Engraphis workspace across devices.")
ap.add_argument("--db", required=True, help="Path to the v2 database file.")
ap.add_argument("--workspace", required=True, help="Workspace name to sync.")
# Pick exactly one transport: a shared folder (self-hosted, free) or the managed relay.
ap.add_argument("--remote", metavar="DIR",
help="Shared folder both devices can see (Dropbox/iCloud/Syncthing/…).")
ap.add_argument("--relay", "--relay-url", dest="relay", nargs="?", const="",
metavar="URL",
help="Managed cloud relay root (e.g. https://team.engraphis.com). "
"Bare --relay uses ENGRAPHIS_RELAY_URL. Mutually exclusive with --remote.")
ap.add_argument("--relay-token", default=None, metavar="TOKEN",
help="Scoped user token for the relay (defaults to ENGRAPHIS_SYNC_TOKEN "
"or the token saved by the dashboard).")
ap.add_argument("--relay-key", dest="legacy_relay_key", default=None, metavar="KEY",
help=argparse.SUPPRESS)
ap.add_argument("--read-only", action="store_true",
help="Pull only; required for a viewer token without sync:write.")
ap.add_argument("--repo", default=None, help="Restrict the sync to one repo name.")
ap.add_argument("--dry-run", action="store_true",
help="Report what would change; write nothing (locally or to the remote).")
args = ap.parse_args(argv)
# Exactly one transport must be selected.
use_relay = args.relay is not None
if bool(args.remote) == use_relay:
print("error: choose exactly one of --remote <folder> or --relay [<url>]",
file=sys.stderr)
return 2
relay_token = args.relay_token or args.legacy_relay_key
if args.relay_token and args.legacy_relay_key:
print("error: use --relay-token; do not also pass deprecated --relay-key",
file=sys.stderr)
return 2
# Folder sync has no remote entitlement boundary, so it retains the local Pro gate.
# A scoped relay token is checked server-side for owner, role, expiry, and sync
# scopes. Legacy license-key authorization remains a migration-only fallback.
from engraphis.backends.sync_relay import has_sync_token, sync_read_only
from engraphis.licensing import LicenseError, require_feature
has_user_token = bool(relay_token) or has_sync_token()
if not use_relay or not has_user_token:
try:
require_feature("sync")
except LicenseError as exc:
print(f"error: cloud sync requires an active entitlement. {exc}",
file=sys.stderr)
return 2
engine = MemoryEngine.create(args.db)
wid_row = engine.store.conn.execute(
"SELECT id, settings FROM workspaces WHERE name=?", (args.workspace,)).fetchone()
if not wid_row:
print(f"error: no workspace named '{args.workspace}' in {args.db}", file=sys.stderr)
return 2
rid = None
if args.repo:
rid_row = engine.store.conn.execute(
"SELECT id FROM repos WHERE workspace_id=? AND name=?",
(wid_row["id"], args.repo)).fetchone()
if not rid_row:
print(f"error: no repo named '{args.repo}' in workspace '{args.workspace}'",
file=sys.stderr)
return 2
rid = rid_row["id"]
from engraphis.config import settings
from engraphis.backends.sync_folder import get_transport
if use_relay:
# Fail CLOSED here, unlike the local-authorization convention
# (service._workspace_visibility treats malformed settings as shared): this
# path uploads the folder off-device, so unreadable settings must block the
# push rather than silently treat a possibly-personal folder as shared.
try:
workspace_settings = json.loads(wid_row["settings"] or "{}")
except (TypeError, ValueError):
workspace_settings = None
if not isinstance(workspace_settings, dict):
print(
"error: workspace settings are unreadable; refusing to upload to the "
"shared-account relay (the folder could be marked personal)",
file=sys.stderr,
)
return 2
visibility = workspace_settings.get("visibility")
if visibility == "personal":
print(
"error: personal workspaces are device-local and cannot be uploaded "
"to the shared-account relay",
file=sys.stderr,
)
return 2
if visibility not in (None, "", "shared"):
print(
"error: workspace visibility is invalid; refusing to upload to the "
"shared-account relay",
file=sys.stderr,
)
return 2
# Namespace the relay by workspace NAME (not the per-device local id) so every
# device on the account lands in one bucket; account isolation is enforced
# server-side by the scoped token owner. See engraphis/inspector/sync_relay.py.
relay_url = args.relay or settings.relay_url
if not relay_url:
print("error: --relay needs a URL — pass --relay <url> or set ENGRAPHIS_RELAY_URL",
file=sys.stderr)
return 2
try:
transport = get_transport("relay", base_url=relay_url,
workspace_id=args.workspace,
license_key=relay_token)
except ValueError as exc:
# A custom URL may contain credentials or signed query parameters. The
# validator's fixed reason is actionable without reflecting the endpoint.
print(f"error: could not open relay: {exc}", file=sys.stderr)
return 2
else:
try:
transport = get_transport("folder", root=args.remote)
except (ValueError, OSError) as exc:
print(f"error: could not open sync folder '{args.remote}': {exc}", file=sys.stderr)
return 2
engine_sync = SyncEngine(engine.store, embedder=engine.embedder,
vector_index=engine.index,
allowed_workspaces=settings.allowed_workspaces or None)
# Honor the same durable, fail-closed device policy as dashboard auto-sync. This
# matters for member/admin tokens too: a device explicitly configured download-only
# must not silently regain upload authority merely because this CLI runs after a
# process restart.
read_only = bool(args.read_only or (use_relay and sync_read_only()))
report = engine_sync.sync(
transport,
wid_row["id"],
repo_id=rid,
dry_run=args.dry_run,
push=not read_only,
)
print(json.dumps(report, indent=2))
t = report["totals"]
verb = "would sync" if args.dry_run else "synced"
print(
f"{verb}: {'read-only · ' if report.get('read_only') else ''}"
f"exported {report['exported_memories']} memories · "
f"pulled {report['peers_applied']} peer(s) · "
f"+{t['added']} new, {t['updated']} updated, {t['unchanged']} unchanged, "
f"+{t['links_added']} links"
+ (f" · {t['rejected']} rejected" if t.get("rejected") else ""),
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())