sandesh-relay 0.2.2__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.
- sandesh/__init__.py +9 -0
- sandesh/cli.py +621 -0
- sandesh/data/usage-scenarios.md +347 -0
- sandesh/mcp_server.py +726 -0
- sandesh/migrate.py +463 -0
- sandesh/migrations/.gitkeep +0 -0
- sandesh/migrations/0001-baseline.sql +46 -0
- sandesh/migrations/0002-drop-message-status.rollback.sql +6 -0
- sandesh/migrations/0002-drop-message-status.sql +25 -0
- sandesh/migrations/0003-project-tracker.rollback.sql +19 -0
- sandesh/migrations/0003-project-tracker.sql +45 -0
- sandesh/migrations/0004-xproj-grant.rollback.sql +18 -0
- sandesh/migrations/0004-xproj-grant.sql +41 -0
- sandesh/migrations/0005-message-fts.rollback.sql +5 -0
- sandesh/migrations/0005-message-fts.sql +20 -0
- sandesh/notify.py +103 -0
- sandesh/sandesh_db.py +1193 -0
- sandesh/schema/.gitkeep +0 -0
- sandesh/schema/current-schema.json +238 -0
- sandesh/schema/schema.meta.json +45 -0
- sandesh_relay-0.2.2.dist-info/METADATA +292 -0
- sandesh_relay-0.2.2.dist-info/RECORD +25 -0
- sandesh_relay-0.2.2.dist-info/WHEEL +4 -0
- sandesh_relay-0.2.2.dist-info/entry_points.txt +3 -0
- sandesh_relay-0.2.2.dist-info/licenses/LICENSE +675 -0
sandesh/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""sandesh — SQLite-backed multi-project messaging for cooperating agent sessions."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata as _metadata
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _metadata.version("sandesh-relay")
|
|
7
|
+
except _metadata.PackageNotFoundError: # running from a source checkout, not installed
|
|
8
|
+
__version__ = "0+unknown"
|
|
9
|
+
|
sandesh/cli.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cli.py — command-line interface for Sandesh (standalone, multi-project).
|
|
3
|
+
|
|
4
|
+
setup / projects — provision + list projects
|
|
5
|
+
register / unregister / addressbook — the addressbook (durable identities)
|
|
6
|
+
send / reply / inbox / fetch / thread — messages
|
|
7
|
+
notify — block until 'to' mail arrives (the watcher)
|
|
8
|
+
|
|
9
|
+
Every data command needs a project: `--project <id>` or $SANDESH_PROJECT.
|
|
10
|
+
The store lives at <data_home>/sandesh/projects/<project_id>/.
|
|
11
|
+
The caller's own address for send/reply/inbox/fetch comes from --from/--to or
|
|
12
|
+
$SANDESH_ADDRESS (falling back to $WF_TRACK).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from sandesh import __version__
|
|
20
|
+
from sandesh import sandesh_db as sdb
|
|
21
|
+
from sandesh import notify as _notify
|
|
22
|
+
from sandesh import migrate as _migrate
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _project(args):
|
|
26
|
+
p = getattr(args, "project", None) or os.environ.get("SANDESH_PROJECT")
|
|
27
|
+
if not p:
|
|
28
|
+
sys.exit("[sandesh] ERROR: pass --project <id> (or set $SANDESH_PROJECT).")
|
|
29
|
+
return p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ctx(args):
|
|
33
|
+
"""(project_id, store_dir, connection)."""
|
|
34
|
+
project = _project(args)
|
|
35
|
+
store = sdb.store_dir(project)
|
|
36
|
+
return project, store, sdb.connect()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _self_addr(args, flag):
|
|
40
|
+
return getattr(args, flag, None) or os.environ.get("SANDESH_ADDRESS") or os.environ.get("WF_TRACK")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _split(csv):
|
|
44
|
+
return [x.strip() for x in csv.split(",") if x.strip()] if csv else []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_body(args):
|
|
48
|
+
if getattr(args, "body_file", None):
|
|
49
|
+
with open(args.body_file, encoding="utf-8") as fh:
|
|
50
|
+
return fh.read()
|
|
51
|
+
return getattr(args, "body", None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
|
|
56
|
+
def cmd_setup(args):
|
|
57
|
+
project = _project(args)
|
|
58
|
+
store = sdb.setup(project)
|
|
59
|
+
print(f"project {project!r} ready → {store}")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_projects(args):
|
|
64
|
+
con = sdb.connect()
|
|
65
|
+
try:
|
|
66
|
+
if getattr(args, "all", False):
|
|
67
|
+
rows = con.execute(
|
|
68
|
+
"SELECT project_id, state, xproj_granted_at FROM project "
|
|
69
|
+
"ORDER BY project_id").fetchall()
|
|
70
|
+
else:
|
|
71
|
+
rows = con.execute(
|
|
72
|
+
"SELECT project_id, state, xproj_granted_at FROM project "
|
|
73
|
+
"WHERE state != 'tombstoned' ORDER BY project_id").fetchall()
|
|
74
|
+
if not rows:
|
|
75
|
+
print("(no projects set up)")
|
|
76
|
+
return 0
|
|
77
|
+
print(f"{'PROJECT':20} {'STATE':10} CROSS-PROJECT")
|
|
78
|
+
for r in rows:
|
|
79
|
+
print(f"{r['project_id']:20} {r['state']:10} "
|
|
80
|
+
f"{'✓' if r['xproj_granted_at'] else '-'}")
|
|
81
|
+
finally:
|
|
82
|
+
con.close()
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_register(args):
|
|
87
|
+
project, _, con = _ctx(args)
|
|
88
|
+
try:
|
|
89
|
+
sdb.register(con, args.address, kind=args.kind, display_name=args.name,
|
|
90
|
+
by=args.address, project=project)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
sys.exit(f"[sandesh] {exc}")
|
|
93
|
+
print(f"registered: {args.address} (project={project}, kind={args.kind or '-'})")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_unregister(args):
|
|
98
|
+
project, _, con = _ctx(args)
|
|
99
|
+
requester = _self_addr(args, "as_")
|
|
100
|
+
if not requester:
|
|
101
|
+
sys.exit("[sandesh] ERROR: pass --as '<your address>' (or set $SANDESH_ADDRESS).")
|
|
102
|
+
try:
|
|
103
|
+
verdict, pid = sdb.unregister(con, args.address, requester=requester, project=project)
|
|
104
|
+
except (ValueError, PermissionError) as exc:
|
|
105
|
+
sys.exit(f"[sandesh] {exc}")
|
|
106
|
+
if verdict == "tombstoned":
|
|
107
|
+
print(f"tombstone set on {args.address} (notifier pid {pid}). It stops within one poll; "
|
|
108
|
+
f"re-run once `addressbook` shows it offline.")
|
|
109
|
+
return 3
|
|
110
|
+
print(f"unregistered: {args.address}")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_addressbook(args):
|
|
115
|
+
project, _, con = _ctx(args)
|
|
116
|
+
book = sdb.addressbook(con, project)
|
|
117
|
+
if not book:
|
|
118
|
+
print(f"addressbook ({project}): empty")
|
|
119
|
+
return 0
|
|
120
|
+
print(f"{'ADDRESS':22} {'KIND':9} {'STATUS':9} {'LISTENING':10} REGISTERED")
|
|
121
|
+
for b in book:
|
|
122
|
+
print(f"{b['address']:22} {b['kind'] or '-':9} "
|
|
123
|
+
f"{'active' if b['active'] else 'inactive':9} "
|
|
124
|
+
f"{'● live' if b['listening'] else '○ offline':10} {b['registered_at']}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_send(args):
|
|
129
|
+
project, store, con = _ctx(args)
|
|
130
|
+
sender = _self_addr(args, "from_")
|
|
131
|
+
if not sender:
|
|
132
|
+
sys.exit("[sandesh] ERROR: pass --from '<your address>' (or set $SANDESH_ADDRESS).")
|
|
133
|
+
try:
|
|
134
|
+
mid = sdb.send(con, store, sender, to=_split(args.to), cc=_split(args.cc),
|
|
135
|
+
subject=args.subject, kind=args.kind, body_text=_read_body(args),
|
|
136
|
+
project=project)
|
|
137
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
138
|
+
sys.exit(f"[sandesh] {exc}")
|
|
139
|
+
kind = "subject-only" if not _read_body(args) else "with body"
|
|
140
|
+
print(f"sent #{mid} ({kind}) from {sender} → to:[{args.to or ''}] cc:[{args.cc or ''}]")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_reply(args):
|
|
145
|
+
project, store, con = _ctx(args)
|
|
146
|
+
sender = _self_addr(args, "from_")
|
|
147
|
+
if not sender:
|
|
148
|
+
sys.exit("[sandesh] ERROR: pass --from '<your address>' (or set $SANDESH_ADDRESS).")
|
|
149
|
+
try:
|
|
150
|
+
mid = sdb.reply(con, store, args.to_msg, sender, subject=args.subject,
|
|
151
|
+
body_text=_read_body(args), reply_all=args.all,
|
|
152
|
+
project=project)
|
|
153
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
154
|
+
sys.exit(f"[sandesh] {exc}")
|
|
155
|
+
print(f"replied #{mid} to #{args.to_msg}")
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _render(items, recipient):
|
|
160
|
+
if not items:
|
|
161
|
+
print(f"(no unread messages for {recipient})")
|
|
162
|
+
return
|
|
163
|
+
print(f"═══ {len(items)} message(s) · {recipient} ═══\n")
|
|
164
|
+
for it in items:
|
|
165
|
+
ref = f" · ↳ re #{it['in_reply_to'][0]} \"{it['in_reply_to'][1]}\"" if it["in_reply_to"] else ""
|
|
166
|
+
tag = " · subject-only" if it["body"] is None else ""
|
|
167
|
+
print(f"[#{it['id']}] {it['from']} · {it['created_at']} · {it['role']}{ref}{tag}")
|
|
168
|
+
print(f" {it['subject']}")
|
|
169
|
+
if it["body"] is not None:
|
|
170
|
+
print(" ───────────────")
|
|
171
|
+
for line in it["body"].rstrip("\n").splitlines():
|
|
172
|
+
print(f" {line}")
|
|
173
|
+
print()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_inbox(args):
|
|
177
|
+
_, _, con = _ctx(args)
|
|
178
|
+
who = _self_addr(args, "to")
|
|
179
|
+
if not who:
|
|
180
|
+
sys.exit("[sandesh] ERROR: pass --to '<address>' (or set $SANDESH_ADDRESS).")
|
|
181
|
+
try:
|
|
182
|
+
rows = sdb.inbox(con, who, unread_only=not args.all,
|
|
183
|
+
sender=args.from_, sender_project=args.from_project,
|
|
184
|
+
kind=args.kind, since=args.since, until=args.until,
|
|
185
|
+
subject_like=args.subject)
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
print(f"{'#':>5} {'FROM':16} {'ROLE':4} {'READ':5} SUBJECT")
|
|
190
|
+
for r in rows:
|
|
191
|
+
print(f"{r['id']:>5} {r['from_addr']:16} {r['role']:4} "
|
|
192
|
+
f"{'·' if r['read_at'] is None else '✓':5} {r['subject']}")
|
|
193
|
+
print(f"({len(rows)} {'unread' if not args.all else 'total'})")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_fetch(args):
|
|
198
|
+
_, store, con = _ctx(args)
|
|
199
|
+
who = _self_addr(args, "to")
|
|
200
|
+
if not who:
|
|
201
|
+
sys.exit("[sandesh] ERROR: pass --to '<address>' (or set $SANDESH_ADDRESS).")
|
|
202
|
+
try:
|
|
203
|
+
items = sdb.fetch(con, store, who, mark=not args.peek,
|
|
204
|
+
sender=args.from_, sender_project=args.from_project,
|
|
205
|
+
kind=args.kind, since=args.since, until=args.until,
|
|
206
|
+
subject_like=args.subject)
|
|
207
|
+
except ValueError as exc:
|
|
208
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
_render(items, who)
|
|
211
|
+
if items and not args.peek:
|
|
212
|
+
print(f"(marked {len(items)} read)")
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_thread(args):
|
|
217
|
+
_, _, con = _ctx(args)
|
|
218
|
+
chain = sdb.thread(con, args.id)
|
|
219
|
+
if not chain:
|
|
220
|
+
sys.exit(f"[sandesh] no such message #{args.id}")
|
|
221
|
+
for m in chain:
|
|
222
|
+
if isinstance(m, dict) and "warning" in m: # tombstoned hole (§S2)
|
|
223
|
+
print(m["warning"])
|
|
224
|
+
continue
|
|
225
|
+
ind = " " if m["in_reply_to"] else ""
|
|
226
|
+
print(f"{ind}#{m['id']} {m['from_addr']} · {m['created_at']}")
|
|
227
|
+
print(f"{ind} {m['subject']}")
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_notify(args):
|
|
232
|
+
return _notify.run(_project(args), args.to, args.timeout)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_migrate(args):
|
|
236
|
+
# Delegate to the migration engine (heavy yoyo/jsonschema imports stay lazy
|
|
237
|
+
# inside migrate.py). The dep guard there exits non-zero with a friendly hint
|
|
238
|
+
# when the [migrate] extra is absent.
|
|
239
|
+
return _migrate.cmd_migrate(args)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def cmd_grant(args):
|
|
243
|
+
con = sdb.connect()
|
|
244
|
+
try:
|
|
245
|
+
sdb.grant_xproj(con, args.project, by=args.by)
|
|
246
|
+
except (ValueError, PermissionError) as exc:
|
|
247
|
+
# Print explicitly (not via SystemExit's message) so in-process callers
|
|
248
|
+
# that capture stderr still see the error text.
|
|
249
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
finally:
|
|
252
|
+
con.close()
|
|
253
|
+
print(f"cross-project sending granted to project {args.project!r} (by {args.by})")
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_revoke(args):
|
|
258
|
+
con = sdb.connect()
|
|
259
|
+
try:
|
|
260
|
+
sdb.revoke_xproj(con, args.project, by=args.by)
|
|
261
|
+
except (ValueError, PermissionError) as exc:
|
|
262
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
finally:
|
|
265
|
+
con.close()
|
|
266
|
+
print(f"cross-project sending revoked for project {args.project!r} (by {args.by})")
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_archive(args):
|
|
271
|
+
con = sdb.connect()
|
|
272
|
+
try:
|
|
273
|
+
if args.dry_run:
|
|
274
|
+
watchers = sdb.archive_preview(con, args.project, args.by)
|
|
275
|
+
print(f"[dry-run] project {args.project!r} would become archived")
|
|
276
|
+
if watchers:
|
|
277
|
+
print(f"[dry-run] watchers to evict ({len(watchers)}):")
|
|
278
|
+
for addr in watchers:
|
|
279
|
+
print(f" {addr}")
|
|
280
|
+
else:
|
|
281
|
+
print("[dry-run] watchers to evict: none")
|
|
282
|
+
print("[dry-run] nothing written")
|
|
283
|
+
return 0
|
|
284
|
+
sdb.archive(con, args.project, args.by, force=args.force)
|
|
285
|
+
except (ValueError, PermissionError, RuntimeError) as exc:
|
|
286
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
287
|
+
sys.exit(1)
|
|
288
|
+
finally:
|
|
289
|
+
con.close()
|
|
290
|
+
print(f"archived project {args.project!r} (by {args.by}) — "
|
|
291
|
+
f"read-only until unarchived; nothing deleted")
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_unarchive(args):
|
|
296
|
+
con = sdb.connect()
|
|
297
|
+
try:
|
|
298
|
+
if args.dry_run:
|
|
299
|
+
sdb.unarchive_preview(con, args.project, args.by)
|
|
300
|
+
print(f"[dry-run] project {args.project!r} would become active")
|
|
301
|
+
print("[dry-run] nothing written")
|
|
302
|
+
return 0
|
|
303
|
+
sdb.unarchive(con, args.project, args.by)
|
|
304
|
+
except (ValueError, PermissionError, RuntimeError) as exc:
|
|
305
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
306
|
+
sys.exit(1)
|
|
307
|
+
finally:
|
|
308
|
+
con.close()
|
|
309
|
+
print(f"unarchived project {args.project!r} (by {args.by}) — active again")
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cmd_tombstone(args):
|
|
314
|
+
con = sdb.connect()
|
|
315
|
+
try:
|
|
316
|
+
if args.dry_run:
|
|
317
|
+
counts = sdb.tombstone_preview(con, args.project, args.by)
|
|
318
|
+
print(f"[dry-run] project {args.project!r} would become tombstoned:")
|
|
319
|
+
print(f" internal messages: {counts['internal_messages']} (rows purged)")
|
|
320
|
+
print(f" body files: {counts['body_files']} (deleted from disk)")
|
|
321
|
+
print(f" cross-project messages: {counts['cross_project_messages']} "
|
|
322
|
+
f"(rows survive; their bodies are lost)")
|
|
323
|
+
print("[dry-run] nothing written")
|
|
324
|
+
return 0
|
|
325
|
+
if not args.yes:
|
|
326
|
+
if not sys.stdin.isatty():
|
|
327
|
+
print(f"[sandesh] tombstoning project {args.project!r} is destructive "
|
|
328
|
+
f"and irreversible — pass --yes to confirm "
|
|
329
|
+
f"(stdin is not a terminal, cannot prompt)", file=sys.stderr)
|
|
330
|
+
sys.exit(1)
|
|
331
|
+
answer = input(
|
|
332
|
+
f"tombstone project {args.project!r}? This permanently purges its "
|
|
333
|
+
f"internal messages and deletes its body folder. [y/N] ")
|
|
334
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
335
|
+
print("aborted — nothing changed")
|
|
336
|
+
return 1
|
|
337
|
+
sdb.tombstone_project(con, args.project, args.by, force=args.force)
|
|
338
|
+
except (ValueError, PermissionError, RuntimeError) as exc:
|
|
339
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
finally:
|
|
342
|
+
con.close()
|
|
343
|
+
print(f"tombstoned project {args.project!r} (by {args.by}) — internal history "
|
|
344
|
+
f"purged, body folder deleted; cross-project envelopes survive")
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cmd_consolidate(args):
|
|
349
|
+
summaries = sdb.consolidate()
|
|
350
|
+
if not summaries:
|
|
351
|
+
print("nothing to consolidate — no legacy per-project stores found.")
|
|
352
|
+
return 0
|
|
353
|
+
for entry in summaries:
|
|
354
|
+
if entry.get("skipped"):
|
|
355
|
+
print(f"skipped {entry['project_id']}: not a legacy store "
|
|
356
|
+
f"({entry['reason']}) — file left untouched")
|
|
357
|
+
continue
|
|
358
|
+
print(f"consolidated {entry['project_id']}: "
|
|
359
|
+
f"{entry['messages_imported']} message(s), "
|
|
360
|
+
f"{entry['addresses_imported']} address(es) → sandesh.db.pre-global")
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# --------------------------------------------------------------------------- #
|
|
365
|
+
|
|
366
|
+
def cmd_search(args):
|
|
367
|
+
con = sdb.connect()
|
|
368
|
+
try:
|
|
369
|
+
try:
|
|
370
|
+
result = sdb.search(con, args.to, args.query, limit=args.limit,
|
|
371
|
+
offset=args.offset,
|
|
372
|
+
sender_project=args.from_project)
|
|
373
|
+
except ValueError as exc:
|
|
374
|
+
print(f"[sandesh] {exc}", file=sys.stderr)
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
finally:
|
|
377
|
+
con.close()
|
|
378
|
+
if result.get("reindexed"):
|
|
379
|
+
print("(index was empty — reindexed before searching)")
|
|
380
|
+
if not result["hits"]:
|
|
381
|
+
print(f"(no matches for {args.query!r})")
|
|
382
|
+
for h in result["hits"]:
|
|
383
|
+
print(f"[#{h['id']}] {h['from']} · {h['created_at']}")
|
|
384
|
+
print(f" {h['subject']}")
|
|
385
|
+
print(f" {h['snippet']}")
|
|
386
|
+
print(f"total: {result['total']}")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def cmd_reindex(args):
|
|
391
|
+
con = sdb.connect()
|
|
392
|
+
try:
|
|
393
|
+
n = sdb.reindex(con)
|
|
394
|
+
finally:
|
|
395
|
+
con.close()
|
|
396
|
+
print(f"reindexed {n} message(s)")
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def build_parser():
|
|
401
|
+
# --project is shared so it works BOTH before and after the subcommand:
|
|
402
|
+
# sandesh --project X setup AND sandesh setup --project X
|
|
403
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
404
|
+
# SUPPRESS: an absent --project in one position must not clobber a value given in
|
|
405
|
+
# the other (so it works both before AND after the subcommand).
|
|
406
|
+
common.add_argument("--project", default=argparse.SUPPRESS,
|
|
407
|
+
help="project id (overrides $SANDESH_PROJECT)")
|
|
408
|
+
|
|
409
|
+
ap = argparse.ArgumentParser(prog="sandesh", parents=[common],
|
|
410
|
+
description="Sandesh messaging CLI (standalone).")
|
|
411
|
+
ap.add_argument("--version", action="version", version=f"sandesh {__version__}")
|
|
412
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
413
|
+
|
|
414
|
+
sub.add_parser("setup", parents=[common],
|
|
415
|
+
help="provision a project (create store + init DB)").set_defaults(fn=cmd_setup)
|
|
416
|
+
p = sub.add_parser("projects", parents=[common], help="list set-up projects")
|
|
417
|
+
p.add_argument("--all", action="store_true",
|
|
418
|
+
help="include tombstoned projects (permanent markers)")
|
|
419
|
+
p.set_defaults(fn=cmd_projects)
|
|
420
|
+
|
|
421
|
+
p = sub.add_parser("register", parents=[common], help="self-register an address")
|
|
422
|
+
p.add_argument("--address", required=True)
|
|
423
|
+
p.add_argument("--kind", choices=["mainline", "track"])
|
|
424
|
+
p.add_argument("--name")
|
|
425
|
+
p.set_defaults(fn=cmd_register)
|
|
426
|
+
|
|
427
|
+
p = sub.add_parser("unregister", parents=[common], help="remove an address (Mainline: anyone; else: self)")
|
|
428
|
+
p.add_argument("--address", required=True)
|
|
429
|
+
p.add_argument("--as", dest="as_", help="your address (or $SANDESH_ADDRESS)")
|
|
430
|
+
p.set_defaults(fn=cmd_unregister)
|
|
431
|
+
|
|
432
|
+
sub.add_parser("addressbook", parents=[common],
|
|
433
|
+
help="list participants + who's listening").set_defaults(fn=cmd_addressbook)
|
|
434
|
+
|
|
435
|
+
p = sub.add_parser("send", parents=[common], help="send a message")
|
|
436
|
+
p.add_argument("--from", dest="from_", help="sender (or $SANDESH_ADDRESS)")
|
|
437
|
+
p.add_argument("--to", help="comma-separated recipients (or 'all-tracks')")
|
|
438
|
+
p.add_argument("--cc")
|
|
439
|
+
p.add_argument("--subject", required=True)
|
|
440
|
+
p.add_argument("--kind", choices=["request", "directive", "fyi"])
|
|
441
|
+
p.add_argument("--body", help="inline body text")
|
|
442
|
+
p.add_argument("--body-file", dest="body_file", help="md file as body (omit → subject-only)")
|
|
443
|
+
p.set_defaults(fn=cmd_send)
|
|
444
|
+
|
|
445
|
+
p = sub.add_parser("reply", parents=[common], help="reply to a message")
|
|
446
|
+
p.add_argument("--to-msg", dest="to_msg", type=int, required=True)
|
|
447
|
+
p.add_argument("--from", dest="from_")
|
|
448
|
+
p.add_argument("--subject")
|
|
449
|
+
p.add_argument("--body")
|
|
450
|
+
p.add_argument("--body-file", dest="body_file")
|
|
451
|
+
p.add_argument("--all", action="store_true", help="reply-all (cc the parent's recipients)")
|
|
452
|
+
p.set_defaults(fn=cmd_reply)
|
|
453
|
+
|
|
454
|
+
# CR-SAN-026 §S3: server-side filter flags, mapped 1:1 onto the lib's
|
|
455
|
+
# inbox/fetch filter params. --from-project is the headline — the
|
|
456
|
+
# cross-project proxy stream (only mail whose SENDER belongs to that
|
|
457
|
+
# sibling project).
|
|
458
|
+
p = sub.add_parser("inbox", parents=[common], help="list a recipient's messages")
|
|
459
|
+
p.add_argument("--to")
|
|
460
|
+
p.add_argument("--all", action="store_true", help="include already-read")
|
|
461
|
+
p.add_argument("--from-project", dest="from_project",
|
|
462
|
+
help="only mail whose sender belongs to this project "
|
|
463
|
+
"(the cross-project proxy stream)")
|
|
464
|
+
p.add_argument("--from", dest="from_", help="only mail from this exact sender address")
|
|
465
|
+
p.add_argument("--kind", help="only this message kind (request/directive/fyi)")
|
|
466
|
+
p.add_argument("--since", help="only mail at/after this time "
|
|
467
|
+
"(YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS', inclusive)")
|
|
468
|
+
p.add_argument("--until", help="only mail at/before this time "
|
|
469
|
+
"(YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS', inclusive; "
|
|
470
|
+
"date-only means end of that day)")
|
|
471
|
+
p.add_argument("--subject", help="case-insensitive substring match on subject")
|
|
472
|
+
p.set_defaults(fn=cmd_inbox)
|
|
473
|
+
|
|
474
|
+
p = sub.add_parser("fetch", parents=[common], help="consolidate + read unread messages")
|
|
475
|
+
p.add_argument("--to")
|
|
476
|
+
p.add_argument("--peek", action="store_true", help="render without marking read")
|
|
477
|
+
p.add_argument("--from-project", dest="from_project",
|
|
478
|
+
help="only mail whose sender belongs to this project "
|
|
479
|
+
"(the cross-project proxy stream)")
|
|
480
|
+
p.add_argument("--from", dest="from_", help="only mail from this exact sender address")
|
|
481
|
+
p.add_argument("--kind", help="only this message kind (request/directive/fyi)")
|
|
482
|
+
p.add_argument("--since", help="only mail at/after this time "
|
|
483
|
+
"(YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS', inclusive)")
|
|
484
|
+
p.add_argument("--until", help="only mail at/before this time "
|
|
485
|
+
"(YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS', inclusive; "
|
|
486
|
+
"date-only means end of that day)")
|
|
487
|
+
p.add_argument("--subject", help="case-insensitive substring match on subject")
|
|
488
|
+
p.set_defaults(fn=cmd_fetch)
|
|
489
|
+
|
|
490
|
+
p = sub.add_parser("thread", parents=[common], help="show a message's reply chain")
|
|
491
|
+
p.add_argument("--id", type=int, required=True)
|
|
492
|
+
p.set_defaults(fn=cmd_thread)
|
|
493
|
+
|
|
494
|
+
p = sub.add_parser("notify", parents=[common], help="block until 'to' mail arrives (the mailbox watcher)")
|
|
495
|
+
p.add_argument("--to", required=True)
|
|
496
|
+
p.add_argument("--timeout", type=int, default=_notify.DEFAULT_TIMEOUT_SECS)
|
|
497
|
+
p.set_defaults(fn=cmd_notify)
|
|
498
|
+
|
|
499
|
+
# CR-SAN-022 DEC-B: the migrate engine targets the single global DB, so the
|
|
500
|
+
# migrate subparser deliberately does NOT inherit the common --project
|
|
501
|
+
# parent (`sandesh migrate --project X` is an argparse error). The
|
|
502
|
+
# pre-subcommand `sandesh --project X migrate` form still parses via the
|
|
503
|
+
# top-level parser; migrate simply ignores the value.
|
|
504
|
+
p = sub.add_parser("migrate",
|
|
505
|
+
help="apply/inspect schema migrations on the global DB "
|
|
506
|
+
"(needs the [migrate] extra)")
|
|
507
|
+
p.add_argument("--status", action="store_true", help="report applied vs pending (no writes)")
|
|
508
|
+
p.add_argument("--rollback", action="store_true",
|
|
509
|
+
help="roll back the single most-recent applied migration")
|
|
510
|
+
p.add_argument("--all", action="store_true",
|
|
511
|
+
help="operate on every project store (apply is fail-fast)")
|
|
512
|
+
p.add_argument("--check", action="store_true",
|
|
513
|
+
help="read-only gate: pending=non-zero, drift=warning (exit zero)")
|
|
514
|
+
p.add_argument("--dump-schema", dest="dump_schema", action="store_true",
|
|
515
|
+
help="emit the live DB shape as JSON to stdout (read-only)")
|
|
516
|
+
p.add_argument("--diff", metavar="OLD_SNAPSHOT",
|
|
517
|
+
help="compare an old snapshot file against the live shape (read-only)")
|
|
518
|
+
p.add_argument("--json", dest="json", action="store_true",
|
|
519
|
+
help="machine-parseable JSON output for --diff")
|
|
520
|
+
p.set_defaults(fn=cmd_migrate)
|
|
521
|
+
|
|
522
|
+
# CR-SAN-022 §S3: one-time import of legacy per-project stores into the
|
|
523
|
+
# global DB. Global like `migrate` — no --project needed (the
|
|
524
|
+
# pre-subcommand `sandesh --project X consolidate` form still parses;
|
|
525
|
+
# the value is simply ignored).
|
|
526
|
+
p = sub.add_parser("consolidate",
|
|
527
|
+
help="import legacy per-project stores into the global DB "
|
|
528
|
+
"(one-time; legacy files become sandesh.db.pre-global)")
|
|
529
|
+
p.set_defaults(fn=cmd_consolidate)
|
|
530
|
+
|
|
531
|
+
# CR-SAN-027 §S3: full-text search over the caller's OWN mail. Parentless
|
|
532
|
+
# like migrate/consolidate — the engine targets the single global DB, so
|
|
533
|
+
# `search --project X` is an argparse error (no per-project routing).
|
|
534
|
+
p = sub.add_parser("search",
|
|
535
|
+
help="full-text search over your own mail (FTS5 syntax: "
|
|
536
|
+
"\"quoted phrases\", AND/OR/NOT)")
|
|
537
|
+
p.add_argument("query", help="the FTS5 query")
|
|
538
|
+
p.add_argument("--to", required=True, help="your address (whose mail to search)")
|
|
539
|
+
p.add_argument("--from-project", dest="from_project",
|
|
540
|
+
help="only hits whose sender belongs to this project")
|
|
541
|
+
p.add_argument("--limit", type=int, default=20, help="page size (default 20)")
|
|
542
|
+
p.add_argument("--offset", type=int, default=0, help="page start (default 0)")
|
|
543
|
+
p.set_defaults(fn=cmd_search)
|
|
544
|
+
|
|
545
|
+
# CR-SAN-027 §S2: rebuild the whole FTS index from the message rows + body
|
|
546
|
+
# files. Parentless and arg-free — global DB, plumbing only.
|
|
547
|
+
p = sub.add_parser("reindex",
|
|
548
|
+
help="rebuild the full-text search index from messages + bodies")
|
|
549
|
+
p.set_defaults(fn=cmd_reindex)
|
|
550
|
+
|
|
551
|
+
# CR-SAN-023 §S2: admin-only verbs (CLI-only — never MCP). Like migrate/
|
|
552
|
+
# consolidate, these deliberately do NOT inherit parents=[common]: their
|
|
553
|
+
# --project is the TARGET project of the grant, not routing context (avoids
|
|
554
|
+
# the dual-position SUPPRESS trap). There is NO `sandesh admin` subcommand —
|
|
555
|
+
# admin assignment happens only in install.sh via $SANDESH_ADMIN (PRD O3).
|
|
556
|
+
p = sub.add_parser("grant",
|
|
557
|
+
help="grant cross-project sending to a project (Sandesh admin only)")
|
|
558
|
+
p.add_argument("--cross-project", dest="cross_project", action="store_true",
|
|
559
|
+
required=True, help="the cross-project access grant (required)")
|
|
560
|
+
p.add_argument("--project", required=True, help="the TARGET project receiving the grant")
|
|
561
|
+
p.add_argument("--by", required=True, help="your admin name (must match the stored admin)")
|
|
562
|
+
p.set_defaults(fn=cmd_grant)
|
|
563
|
+
|
|
564
|
+
p = sub.add_parser("revoke",
|
|
565
|
+
help="revoke a project's cross-project grant (Sandesh admin only)")
|
|
566
|
+
p.add_argument("--cross-project", dest="cross_project", action="store_true",
|
|
567
|
+
required=True, help="the cross-project access grant (required)")
|
|
568
|
+
p.add_argument("--project", required=True, help="the TARGET project losing the grant")
|
|
569
|
+
p.add_argument("--by", required=True, help="your admin name (must match the stored admin)")
|
|
570
|
+
p.set_defaults(fn=cmd_revoke)
|
|
571
|
+
|
|
572
|
+
# CR-SAN-024 §S3: project lifecycle verbs. Parentless like grant/revoke —
|
|
573
|
+
# their --project is the TARGET project, not routing context. Two-tier
|
|
574
|
+
# authz: archive/unarchive take the project's own Mainline (--by), the
|
|
575
|
+
# destructive tombstone takes the install-assigned super-admin (--by) and
|
|
576
|
+
# an interactive confirm (bypass with --yes). All three accept --dry-run
|
|
577
|
+
# (report only, writes nothing).
|
|
578
|
+
p = sub.add_parser("archive",
|
|
579
|
+
help="archive a project — read-only, reversible "
|
|
580
|
+
"(its own Mainline only)")
|
|
581
|
+
p.add_argument("--project", required=True, help="the project to archive")
|
|
582
|
+
p.add_argument("--by", required=True,
|
|
583
|
+
help="the project's own Mainline address")
|
|
584
|
+
p.add_argument("--force", action="store_true",
|
|
585
|
+
help="reap watchers that ignore the eviction tombstone")
|
|
586
|
+
p.add_argument("--dry-run", dest="dry_run", action="store_true",
|
|
587
|
+
help="report watchers to evict + would-be state; write nothing")
|
|
588
|
+
p.set_defaults(fn=cmd_archive)
|
|
589
|
+
|
|
590
|
+
p = sub.add_parser("unarchive",
|
|
591
|
+
help="reactivate an archived project (its own Mainline only)")
|
|
592
|
+
p.add_argument("--project", required=True, help="the project to reactivate")
|
|
593
|
+
p.add_argument("--by", required=True,
|
|
594
|
+
help="the project's own Mainline address")
|
|
595
|
+
p.add_argument("--dry-run", dest="dry_run", action="store_true",
|
|
596
|
+
help="report would-be state; write nothing")
|
|
597
|
+
p.set_defaults(fn=cmd_unarchive)
|
|
598
|
+
|
|
599
|
+
p = sub.add_parser("tombstone",
|
|
600
|
+
help="permanently retire an ARCHIVED project — purges its "
|
|
601
|
+
"internal history + body folder (Sandesh admin only)")
|
|
602
|
+
p.add_argument("--project", required=True, help="the project to tombstone")
|
|
603
|
+
p.add_argument("--by", required=True,
|
|
604
|
+
help="your admin name (must match the stored admin)")
|
|
605
|
+
p.add_argument("--force", action="store_true",
|
|
606
|
+
help="reap watchers that ignore the eviction tombstone")
|
|
607
|
+
p.add_argument("--yes", action="store_true",
|
|
608
|
+
help="skip the interactive confirmation")
|
|
609
|
+
p.add_argument("--dry-run", dest="dry_run", action="store_true",
|
|
610
|
+
help="report would-be purge counts; write nothing")
|
|
611
|
+
p.set_defaults(fn=cmd_tombstone)
|
|
612
|
+
return ap
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def main(argv=None):
|
|
616
|
+
args = build_parser().parse_args(argv)
|
|
617
|
+
return args.fn(args)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
if __name__ == "__main__":
|
|
621
|
+
sys.exit(main())
|