cortad 0.3.8 → 0.3.10

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.
@@ -118,7 +118,7 @@ def _install():
118
118
  import re
119
119
  import sys
120
120
  import time
121
- from urllib.parse import urlsplit
121
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
122
122
 
123
123
  ctx = contextvars.ContextVar("cortad_request", default=None)
124
124
  limit = 65536
@@ -882,6 +882,31 @@ def _install():
882
882
  return False
883
883
  return bool(retrieval_host.search(parts.hostname or "")) or bool(retrieval_path.search(parts.path or ""))
884
884
 
885
+ # A call to one Qdrant collection, by Qdrant's own REST shape (lib/wire.cjs qdrantCall): every
886
+ # PUT, PATCH and DELETE writes, and a POST does unless it is one of Qdrant's reads spelled as a
887
+ # POST. /collections/aliases names no collection: a write of the server itself.
888
+ qdrant_collection = re.compile(r"^/collections/([^/]+)(/.*)?$")
889
+ qdrant_read_post = re.compile(r"^/points(?:/(?:search|query|recommend|discover)(?:/(?:batch|groups))?|/scroll|/count|/facet|/search/matrix/(?:pairs|offsets))?$")
890
+ qdrant_points = re.compile(r"^/points(?:/(?:delete|payload|payload/delete|payload/clear|vectors|vectors/delete|batch))?$")
891
+
892
+ def qdrant_call(url, method):
893
+ try:
894
+ parts = urlsplit(str(url))
895
+ except ValueError:
896
+ return None
897
+ if parts.scheme not in ("http", "https"):
898
+ return None
899
+ m = qdrant_collection.match(parts.path or "")
900
+ if not m:
901
+ return None
902
+ name, rest = m.group(1), m.group(2) or ""
903
+ verb = str(method or "GET").upper()
904
+ write = verb in ("PUT", "PATCH", "DELETE") or (verb == "POST" and not qdrant_read_post.match(rest))
905
+ # "points" changes what the collection holds and a copy stands in for it; "create" makes it;
906
+ # "schema" (an index, an alias, a snapshot, its settings or its deletion) is held, never copied.
907
+ kind = None if not write else "create" if rest == "" and verb == "PUT" else "points" if qdrant_points.match(rest) and verb != "DELETE" else "schema"
908
+ return {"collection": None if name == "aliases" else unquote(name), "rest": rest, "write": write, "kind": kind, "parts": parts}
909
+
885
910
  def passages_from(raw):
886
911
  out, nodes = [], [0]
887
912
 
@@ -1344,6 +1369,11 @@ def _install():
1344
1369
  return False
1345
1370
  elif run_seen["at"] is None or time.monotonic() - run_seen["at"] > run_live_s:
1346
1371
  return False
1372
+ # A read of a Qdrant collection, however its server is named, and any call to a copy this
1373
+ # hook made (copy_of below), go out.
1374
+ q = qdrant_call(url, method)
1375
+ if q and (not q["write"] or (q["collection"] and our_copy.search(q["collection"]))):
1376
+ return False
1347
1377
  try:
1348
1378
  parts = urlsplit(str(url))
1349
1379
  host = (parts.hostname or "").lower()
@@ -1353,6 +1383,150 @@ def _install():
1353
1383
  except Exception:
1354
1384
  return True
1355
1385
 
1386
+ # A write to a Qdrant collection made inside a run goes to a copy of that collection on the same
1387
+ # server, made at the first such write: the collection is read page by page and written under
1388
+ # `<name>_cortad_<pid>`, and from then on every call a run makes to that collection, reads too,
1389
+ # goes to the copy, so the app finds what it wrote. The original is never written. A copy that
1390
+ # cannot be made (over the size a run copies, a server that refused) leaves the write held like
1391
+ # any other. Each copy is reported as a `copy` row and the command deletes them when it ends.
1392
+ # The person's own calls, outside a run, go where they were sent (lib/trace.cjs does the same).
1393
+ import threading
1394
+ our_copy = re.compile(r"_cortad_\d+$")
1395
+ copies = {}
1396
+ copies_lock = threading.Lock()
1397
+ qdrant_cap = 200000
1398
+ # What one process copies at most: collections, and points across them; past it a write is held.
1399
+ copies_max = 8
1400
+ points_max = 300000
1401
+ copied = {"collections": 0, "points": 0}
1402
+ copy_said = set()
1403
+
1404
+ def copy_say(host, collection, why):
1405
+ if (host, collection) in copy_said:
1406
+ return
1407
+ copy_said.add((host, collection))
1408
+ try:
1409
+ sys.stderr.write("cortad: a request Cortad sent made this app write into the Qdrant collection %s at %s. Cortad could not copy that collection on its server (%s), so the write was kept on this machine and answered with a stand-in success.\n" % (collection, host, why))
1410
+ except Exception:
1411
+ pass
1412
+
1413
+ def in_run():
1414
+ req = ctx.get()
1415
+ if req:
1416
+ return bool(req.get("turn"))
1417
+ return run_seen["at"] is not None and time.monotonic() - run_seen["at"] <= run_live_s
1418
+
1419
+ # None to leave the call alone; hold for a write of the server itself; else the collection's
1420
+ # copy when there is one, or none yet for a write, which makes it.
1421
+ def guarded(url, method):
1422
+ call = qdrant_call(url, method)
1423
+ if not call:
1424
+ return None
1425
+ if not call["collection"]:
1426
+ return {"hold": True} if call["write"] and in_run() else None
1427
+ if our_copy.search(call["collection"]) or not in_run():
1428
+ return None
1429
+ key = "%s|%s" % (call["parts"].netloc, call["collection"])
1430
+ copy = copies.get(key)
1431
+ if copy:
1432
+ return {"key": key, "call": call, "copy": copy}
1433
+ if not call["write"]:
1434
+ return None
1435
+ if call["kind"] == "schema":
1436
+ return {"hold": True}
1437
+ if copied["collections"] >= copies_max or copied["points"] >= points_max:
1438
+ copy_say(call["parts"].netloc, call["collection"], "this run already copied %d collections, %d points" % (copied["collections"], copied["points"]))
1439
+ return {"hold": True}
1440
+ return {"key": key, "call": call, "copy": None}
1441
+
1442
+ # Qdrant's own API over urllib, which this file leaves unpatched: never through the app's clients.
1443
+ def qdrant_http(base, key, method, path, body=None):
1444
+ import urllib.error
1445
+ import urllib.request
1446
+ data = json.dumps(body).encode("utf-8") if body is not None else None
1447
+ headers = {"content-type": "application/json"}
1448
+ if key:
1449
+ headers["api-key"] = key
1450
+ req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
1451
+ try:
1452
+ with urllib.request.urlopen(req, timeout=30) as r:
1453
+ return json.loads(r.read().decode("utf-8", "replace") or "null"), r.status
1454
+ except urllib.error.HTTPError as e:
1455
+ return None, e.code
1456
+
1457
+ # The copy made: "empty" when the server holds no such collection yet (the app makes it, under
1458
+ # the copy's name), "points" once every point is across, None when none could be made.
1459
+ def clone_qdrant(base, key, source, clone):
1460
+ at = lambda name: "/collections/" + quote(name, safe="")
1461
+ info, status = qdrant_http(base, key, "GET", at(source))
1462
+ if status == 404:
1463
+ return ("empty", 0)
1464
+ result = (info or {}).get("result") if status == 200 else None
1465
+ if not result:
1466
+ return None
1467
+ if (result.get("points_count") or 0) > qdrant_cap:
1468
+ return None
1469
+ params = (result.get("config") or {}).get("params") or {}
1470
+ made = {"vectors": params.get("vectors")}
1471
+ if params.get("sparse_vectors"):
1472
+ made["sparse_vectors"] = params["sparse_vectors"]
1473
+ if "on_disk_payload" in params:
1474
+ made["on_disk_payload"] = params["on_disk_payload"]
1475
+ if qdrant_http(base, key, "PUT", at(clone), made)[1] != 200:
1476
+ return None
1477
+ try:
1478
+ for field, schema in (result.get("payload_schema") or {}).items():
1479
+ qdrant_http(base, key, "PUT", at(clone) + "/index?wait=true", {"field_name": field, "field_schema": schema.get("params") or schema.get("data_type")})
1480
+ offset = None
1481
+ while True:
1482
+ page, status = qdrant_http(base, key, "POST", at(source) + "/points/scroll", {"limit": 256, "with_payload": True, "with_vector": True, **({"offset": offset} if offset is not None else {})})
1483
+ if status != 200:
1484
+ raise RuntimeError(status)
1485
+ got = (page or {}).get("result") or {}
1486
+ points = got.get("points") or []
1487
+ if points and qdrant_http(base, key, "PUT", at(clone) + "/points?wait=true", {"points": [{"id": p.get("id"), "vector": p.get("vector"), "payload": p.get("payload")} for p in points]})[1] != 200:
1488
+ raise RuntimeError("upsert")
1489
+ offset = got.get("next_page_offset")
1490
+ if offset is None:
1491
+ return ("points", result.get("points_count") or 0)
1492
+ except Exception:
1493
+ qdrant_http(base, key, "DELETE", at(clone))
1494
+ return None
1495
+
1496
+ # The copy's name, made now when none exists; None when none could be made. The key the app
1497
+ # sent is the key the copy is made with, and never leaves this process.
1498
+ def copy_of(g, header_of):
1499
+ with copies_lock:
1500
+ have = copies.get(g["key"])
1501
+ if have:
1502
+ return have
1503
+ parts = g["call"]["parts"]
1504
+ base = "%s://%s" % (parts.scheme, parts.netloc)
1505
+ auth = str(header_of("authorization") or "")
1506
+ bearer = re.match(r"^Bearer\s+(\S+)", auth, re.I)
1507
+ key = str(header_of("api-key") or "") or (bearer.group(1) if bearer else "")
1508
+ clone = "%s_cortad_%d" % (g["call"]["collection"][:40], os.getpid())
1509
+ made = clone_qdrant(base, key, g["call"]["collection"], clone)
1510
+ if not made:
1511
+ copy_say(parts.netloc, g["call"]["collection"], "the copy did not go through")
1512
+ return None
1513
+ copied["collections"] += 1
1514
+ copied["points"] += made[1]
1515
+ copies[g["key"]] = clone
1516
+ write({"copy": {"host": parts.hostname or "", "base": base, "collection": g["call"]["collection"], "clone": clone}})
1517
+ return clone
1518
+
1519
+ def to_copy(url, clone, call):
1520
+ parts = call["parts"]
1521
+ return urlunsplit((parts.scheme, parts.netloc, "/collections/" + quote(clone, safe="") + call["rest"], parts.query, parts.fragment))
1522
+
1523
+ # The copy a call goes to, waited for off the event loop: None to hold the call instead.
1524
+ async def copy_for(g, header_of):
1525
+ import asyncio
1526
+ if g.get("hold"):
1527
+ return None
1528
+ return g["copy"] or await asyncio.get_running_loop().run_in_executor(None, copy_of, g, header_of)
1529
+
1356
1530
  # A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
1357
1531
  # where a SELECT can write through a function) is held.
1358
1532
  # ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
@@ -2468,19 +2642,34 @@ def _install():
2468
2642
  dep(request.url, response.status_code, req=req, caller=caller)
2469
2643
  return response
2470
2644
 
2645
+ def held_answer(request, sent):
2646
+ kind = request.headers.get("content-type", "")
2647
+ held_row(request.url, request.method, sent, kind, ctx.get())
2648
+ return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
2649
+
2471
2650
  # A held call that cannot be answered fails in the app; it is never sent instead.
2472
2651
  def held_httpx(request, sent):
2473
2652
  if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
2474
2653
  return None
2475
- kind = request.headers.get("content-type", "")
2476
- held_row(request.url, request.method, sent, kind, ctx.get())
2477
- return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
2654
+ return held_answer(request, sent)
2655
+
2656
+ # A call to a Qdrant collection goes to the run's copy of it, `clone`; with none, it is held.
2657
+ def routed_httpx(request, g, clone, sent):
2658
+ if clone is None:
2659
+ return held_answer(request, sent)
2660
+ request.url = module.URL(to_copy(request.url, clone, g["call"]))
2661
+ return None
2478
2662
 
2479
2663
  for cls in (module.Client, module.AsyncClient):
2480
2664
  send = cls.send
2481
2665
  if cls is module.Client:
2482
2666
  def sync_send(self, request, *a, _send=send, **k):
2483
2667
  sent = sent_of(request)
2668
+ g = guarded(request.url, request.method)
2669
+ if g is not None:
2670
+ answered = routed_httpx(request, g, None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get)), sent)
2671
+ if answered is not None:
2672
+ return answered
2484
2673
  held = held_httpx(request, sent)
2485
2674
  if held is not None:
2486
2675
  return held
@@ -2504,6 +2693,11 @@ def _install():
2504
2693
  else:
2505
2694
  async def async_send(self, request, *a, _send=send, **k):
2506
2695
  sent = sent_of(request)
2696
+ g = guarded(request.url, request.method)
2697
+ if g is not None:
2698
+ answered = routed_httpx(request, g, await copy_for(g, request.headers.get), sent)
2699
+ if answered is not None:
2700
+ return answered
2507
2701
  held = held_httpx(request, sent)
2508
2702
  if held is not None:
2509
2703
  return held
@@ -2535,6 +2729,9 @@ def _install():
2535
2729
  raw = text(request.body, reply_max)
2536
2730
  if reads_only(raw, request.url, request.headers.get):
2537
2731
  return None
2732
+ return held_answer(request, raw)
2733
+
2734
+ def held_answer(request, raw):
2538
2735
  import datetime
2539
2736
  models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
2540
2737
  kind = request.headers.get("content-type", "")
@@ -2546,6 +2743,12 @@ def _install():
2546
2743
  return response
2547
2744
 
2548
2745
  def sent(self, request, *a, **k):
2746
+ g = guarded(request.url, request.method)
2747
+ if g is not None:
2748
+ clone = None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get))
2749
+ if clone is None:
2750
+ return held_answer(request, text(request.body, reply_max))
2751
+ request.url = to_copy(request.url, clone, g["call"])
2549
2752
  answered = held(request)
2550
2753
  if answered is not None:
2551
2754
  return answered
@@ -2601,7 +2804,18 @@ def _install():
2601
2804
  async def requested(self, method, str_or_url, *a, **k):
2602
2805
  body = k.get("json") if k.get("json") is not None else k.get("data")
2603
2806
  url = whole(self, str_or_url)
2604
- if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name)):
2807
+ g = guarded(url, method)
2808
+ hold = False
2809
+ if g is not None:
2810
+ clone = await copy_for(g, lambda name: header_of(self, k, name))
2811
+ if clone is None:
2812
+ hold = True
2813
+ else:
2814
+ parts = g["call"]["parts"]
2815
+ url = to_copy(url, clone, g["call"])
2816
+ # A session with a base address takes the path alone.
2817
+ str_or_url = url if getattr(self, "_base_url", None) is None else url[len("%s://%s" % (parts.scheme, parts.netloc)):]
2818
+ if hold or (held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name))):
2605
2819
  port = held_port()
2606
2820
  if port is None:
2607
2821
  raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
package/lib/qdrant.mjs CHANGED
@@ -5,8 +5,9 @@ export const QDRANT_CAP = 200_000;
5
5
  const BATCH = 256;
6
6
  const at = (name) => `/collections/${encodeURIComponent(name)}`;
7
7
 
8
+ // `conn.fetch`: the fetch to use, when the caller's global one is wrapped (the hook in the app).
8
9
  async function call(conn, method, path, body) {
9
- const res = await fetch(`${conn.base}${path}`, {
10
+ const res = await (conn.fetch ?? fetch)(`${conn.base}${path}`, {
10
11
  method,
11
12
  headers: { "content-type": "application/json", ...(conn.apiKey ? { "api-key": conn.apiKey } : {}) },
12
13
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -34,7 +35,7 @@ export async function cloneQdrant(conn, source, clone) {
34
35
  const page = await call(conn, "POST", `${at(source)}/points/scroll`, { limit: BATCH, with_payload: true, with_vector: true, ...(offset === undefined ? {} : { offset }) });
35
36
  if (page.points.length) await call(conn, "PUT", `${at(clone)}/points?wait=true`, { points: page.points.map(({ id, vector, payload }) => ({ id, vector, payload })) });
36
37
  offset = page.next_page_offset;
37
- if (offset === null || offset === undefined) return { how: "points" };
38
+ if (offset === null || offset === undefined) return { how: "points", points: info.points_count ?? 0 };
38
39
  }
39
40
  } catch (e) {
40
41
  if (made) await dropQdrant(conn, clone);
package/lib/reach.mjs CHANGED
@@ -4,7 +4,7 @@ import { plural, upper } from "./words.mjs";
4
4
 
5
5
  // One request to each endpoint status lists as not reached, from this machine to the app on its port.
6
6
  // Each body is the one Cortad read that endpoint's client sending (the server's `request`).
7
- // The connect command sends these by itself, one at a time, each tagged x-cortad-turn: reach:auto:<n>
7
+ // The connect command sends these by itself, up to four at a time, each tagged x-cortad-turn: reach:auto:<n>
8
8
  // (sendOurs), signed in only as a test account this machine made. Headers are never the agent's: they
9
9
  // go from here to the app on this machine and nowhere else, and only their names are printed. These
10
10
  // are real requests with real effects.
@@ -31,7 +31,7 @@ export function notUp(runner) {
31
31
  // The rows a call sends and the ones it leaves: the named paths, else every row with a ready request
32
32
  // that is not marked by hand.
33
33
  export function picked(contact, receipts, paths = []) {
34
- const rows = unreached(contact, receipts);
34
+ const rows = unreached(contact, receipts, { retests: true });
35
35
  const named = new Set(Array.isArray(paths) ? paths : []);
36
36
  const chosen = named.size ? rows.filter((m) => named.has(m.path) || named.has(keyOf(m))) : rows.filter((m) => !m.unsure);
37
37
  const sent = chosen.filter((m) => m.request);
@@ -72,15 +72,15 @@ export function reachText({ rows, sent, left, unknown }, answers, how) {
72
72
  ].filter(Boolean).join("\n");
73
73
  }
74
74
 
75
- // Cortad's own test requests, sent by the connect command: one at a time, so what the app prints
76
- // belongs to one of them, each with the app's own origin where one is known. An endpoint that answers
75
+ // Cortad's own test requests, sent by the connect command, each with the app's own origin where one is known. An endpoint that answers
77
76
  // 401 or 403 is asked once more as the test account this machine signed in, when there is one
78
77
  // (`signIn(row)` gives { name, value } or null); the token never leaves this machine.
79
78
  // `stop()`: true when the rest should wait, for the agent's ask; the answers so far come back.
80
79
  // Four at a time: one each in turn left a 22-endpoint app waiting nearly two minutes behind its two
81
80
  // slowest replies (ulaim, 2026-10-06). Answers keep the rows' order; a row is not sent once stop() says so.
81
+ // `onAnswer(answer, i)`: called as each row's answer lands, so one slow endpoint holds back no other's.
82
82
  const AT_ONCE = 4;
83
- export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, fetchImpl = fetch, atOnce = AT_ONCE }) {
83
+ export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, onAnswer = () => {}, fetchImpl = fetch, atOnce = AT_ONCE }) {
84
84
  const app = appUrl(runner);
85
85
  const base = () => ({ ...(origin ? { origin, referer: `${origin}/` } : {}), [RUN_TURN]: `reach:auto:${next()}` });
86
86
  const answers = [];
@@ -101,6 +101,7 @@ export async function sendOurs(rows, { runner, origin, signIn = async () => null
101
101
  if (stopped || stop()) { stopped = true; return; }
102
102
  const i = at++;
103
103
  answers[i] = await one(rows[i]);
104
+ try { onAnswer(answers[i], i); } catch { /* the answer stands whatever its listener did */ }
104
105
  }
105
106
  };
106
107
  await Promise.all(Array.from({ length: Math.min(atOnce, rows.length) }, lane));
@@ -179,7 +180,13 @@ export async function sendOne(m, app, headers, fetchImpl, secrets = []) {
179
180
  res = await fetchImpl(url, { method, headers: json && !typed ? { "content-type": "application/json", ...headers } : headers,
180
181
  ...(json ? { body: JSON.stringify(body) } : {}), redirect: "manual", signal: AbortSignal.timeout(REACH_MS) });
181
182
  } catch (err) {
182
- return { line: `${label}: ${failedSaid(err, app.port || 80)}`, ok: false, status: 0, down: /^(?:ECONNREFUSED|ECONNRESET)$/.test(err?.cause?.code ?? err?.code ?? "") };
183
+ const code = err?.cause?.code ?? err?.code ?? "";
184
+ // Refused, nothing reached the app, and a GET cut off changes nothing: both are sent again
185
+ // (`down`). A POST whose connection was reset may have been taken by the app before it went, so
186
+ // it is an answer nobody saw (`unknown`) and is never sent again by itself.
187
+ const unknown = code === "ECONNRESET" && method !== "GET";
188
+ return { line: `${label}: ${failedSaid(err, app.port || 80)}${unknown ? " It may have reached your app, so Cortad does not send it again by itself." : ""}`, ok: false, status: 0,
189
+ down: !unknown && /^(?:ECONNREFUSED|ECONNRESET)$/.test(code), ...(unknown ? { unknown: true } : {}) };
183
190
  }
184
191
  let text;
185
192
  try { text = await res.text(); } catch { return { line: `${label}: ${res.status}, and the answer had not finished after ${REACH_MS / 1000} seconds.`, ok: false, status: res.status }; }
package/lib/read-text.mjs CHANGED
@@ -87,30 +87,30 @@ const listed = (names) => (names.length > 1 ? `${names.slice(0, -1).join(", ")}
87
87
 
88
88
  // What the connect command does on this machine, from the facts it keeps in the runner's record:
89
89
  // what it started and stops, the database copies, the settings its app runs with, what it loads,
90
- // what a run holds and puts back, and what it keeps and writes outside this repository. At most
91
- // eight lines, the two for the person first.
90
+ // what a run holds and puts back, and what it keeps outside this repository. The first line is the
91
+ // one the terminal says at connect (connectedText); the rest is read when someone asks.
92
92
  export function machineText(f) {
93
93
  if (!f) return ["No connect command is running for this repository now, so nothing Cortad started is running here."];
94
94
  const lines = [
95
95
  f.started
96
- ? `For the person: Cortad started your app in ${f.dir === "." ? "this folder" : f.dir} and stops it, with everything it started for it, when you press Ctrl-C in its terminal; your code and settings files are never edited.`
97
- : `For the person: Cortad uses your app already running on port ${f.port} and leaves it running when you press Ctrl-C in its terminal; your code and settings files are never edited.`,
98
- f.copies === "made"
99
- ? "For the person: Where Cortad could copy a database, your app runs on the copy, made from your own at every connect and deleted when this command ends, so records created in a copy are gone at the next connect; keep records every run needs in your own database. Each Data line in status says what happens to one store."
100
- : "For the person: No copy of a database was made for this session, so what test conversations create stays where your app keeps its data; each Data line in status names one store.",
96
+ ? `Cortad started your app in ${f.dir === "." ? "this folder" : f.dir} and stops it when this command stops. Your code and settings files are never edited.`
97
+ : `Cortad uses your app already running on port ${f.port} and leaves it running. Your code and settings files are never edited.`,
101
98
  ];
99
+ if (f.copies === "made") lines.push("Test conversations save into a copy of your database, made fresh at each connect and deleted when this command stops. Your own data is not changed.");
102
100
  // Env values reach only a process the command started itself.
103
- if (f.started) lines.push(f.changed?.length ? `Your env file values go into your app's process alone, with ${listed(f.changed)} set for this session only; no value is sent to Cortad.` : "Your env file values go into your app's process alone, unchanged; no value is sent to Cortad.");
104
- lines.push(f.hooked ? "A hook loaded into your app records each request that reaches a model and the model calls it makes; request headers stay on this machine and are deleted when the command stops."
105
- : f.proxy ? `A local proxy on port ${f.proxy} sees each request sent to it and the model calls your app makes through it; request headers stay on this machine and are deleted when the command stops.`
106
- : "Nothing is loaded into your app, since Cortad did not start it, so its model calls are not seen.");
101
+ if (f.started) lines.push(`Your env values stay on this machine${f.changed?.length ? `, with ${listed(f.changed)} set for this session only` : ""}. None is sent to Cortad.`);
102
+ lines.push(f.hooked ? "Cortad sees each request that reaches your model and the model calls it makes. Request headers stay on this machine and are deleted when the command stops."
103
+ : f.proxy ? `A local proxy on port ${f.proxy} sees each request sent to it and the model calls your app makes through it. Request headers stay on this machine and are deleted when the command stops.`
104
+ : "Cortad did not start your app, so it does not see the calls your app makes to its model.");
107
105
  // Only the hook holds a write or records a file: through the proxy, or with nothing loaded, both go as the app does them.
108
- lines.push(f.hooked ? "During a run, a write your app sends to a service off this machine, such as a payment or an email, is answered inside your app and never sent, unless it carries a test key, goes to a provider's sandbox, or goes to a host you said yes to on the card; a call named as a read where every call is a POST, such as customers.get, goes out."
109
- : "During a run, a write your app sends to a service off this machine, such as a payment or an email, is sent for real.");
110
- lines.push(f.hooked ? "Files your app writes in its own folder during a run are put back when run_status reads the run's end; its log files are left as they are." : "Files your app writes in its own folder stay as it writes them.");
111
- lines.push("Kept between connects in ~/.cortad on this machine: this repository's key for later runs, the key the test accounts' passwords are made from, the files to put back, a list of what to undo, and your app's output from the last session with the values from your env files masked, which the next connect starts over.");
106
+ lines.push(f.hooked ? "During a run, anything your app would send to an outside service, such as a payment or an email, is answered inside your app and never sent, unless it carries a test key, goes to a provider's sandbox, or goes to a host you allowed on the card. A call that only reads goes out."
107
+ : "During a run, what your app sends to an outside service, such as a payment or an email, is sent for real.");
108
+ lines.push(f.hooked ? "Files your app writes in its own folder during a run are put back when the run ends. Its log files are left as they are." : "Files your app writes in its own folder stay as it writes them.");
109
+ lines.push("Kept in ~/.cortad on this machine between connects: this repository's key, the key the test accounts' passwords are made from, what to put back after a run, and your app's output from the last session with your env values masked.");
112
110
  return lines;
113
111
  }
112
+ // What the terminal says once the app answers: the one line the person needs.
113
+ export const connectedText = (f) => machineText(f).slice(0, 1);
114
114
 
115
115
  export function showText(d, show, page = 1) {
116
116
  if (!d.read || (d.read.complete === false && show !== "reviews")) return "Cortad is still reading this repository.";
package/lib/replay.mjs CHANGED
@@ -37,6 +37,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
37
37
  let newestTable = null;
38
38
  // Each server a hooked process opened a connection to, by host and port.
39
39
  const conns = new Map();
40
+ // The Qdrant collections the hook copied for a run's writes, by server and name (lib/stores.mjs drops them).
41
+ const copies = new Map();
40
42
  // The model's last words and status for each of a run's turns on an endpoint the app answers
41
43
  // later, by turn (lib/wire.cjs answeredLater).
42
44
  const answers = new Map();
@@ -48,7 +50,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
48
50
  // requires, to one without.
49
51
  const bunHook = /\s/.test(HOOK) ? (() => {
50
52
  const dir = mkdtempSync(join(tmpdir(), "cortad-"));
51
- for (const name of ["trace.cjs", "wire.cjs", "tied.cjs"]) copyFileSync(join(HERE, name), join(dir, name));
53
+ for (const name of ["trace.cjs", "wire.cjs", "tied.cjs", "qdrant.mjs"]) copyFileSync(join(HERE, name), join(dir, name));
52
54
  return join(dir, "trace.cjs");
53
55
  })() : HOOK;
54
56
  // The hooks are handed to whatever is started: a Node app loads the first, a Bun app the same file
@@ -96,6 +98,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
96
98
  if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
97
99
  if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
98
100
  if (row.conn) { const { host, port } = row.conn; if (typeof host === "string" && Number.isInteger(port)) conns.set(`${host}:${port}`, { host, port }); continue; }
101
+ if (row.copy) { const { base, collection, clone } = row.copy; if ([base, collection, clone].every((v) => typeof v === "string" && v)) copies.set(`${base}|${collection}`, { base, collection, clone }); continue; }
99
102
  if (row.answer) {
100
103
  const { turn, status, reply } = row.answer;
101
104
  if (TURN.test(turn ?? "")) answers.set(turn, { status: Number.isInteger(status) ? status : 0, reply: typeof reply === "string" ? reply : "" });
@@ -158,6 +161,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
158
161
  registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
159
162
  // The servers the app has connected to so far (lib/stores.mjs names the ones no setting does).
160
163
  connections: () => { poll(); return [...conns.values()]; },
164
+ // The collections the hook copied on a Qdrant server for a run's writes, to be deleted at the end.
165
+ copies: () => { poll(); return [...copies.values()]; },
161
166
  // The model's last words for a run's turn on an endpoint the app answers later, once the hook
162
167
  // has written them; null at `until`, or once `signal` aborts. `forget` drops a turn sent again.
163
168
  answered: async (turn, until, signal) => {
package/lib/spec.mjs CHANGED
@@ -11,10 +11,20 @@ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import
11
11
  export const SPEC = "(?:cortad(?:@[A-Za-z0-9._-]+)?|/[A-Za-z0-9._/-]+)";
12
12
  const SAFE = new RegExp(`^${SPEC}$`);
13
13
 
14
+ // A pin older than this very version loses to it: a connect run as cortad@0.3.8 wrote a skill and an
15
+ // MCP server pinned to 0.3.7, and the agent asked whether the two were meant to mix.
14
16
  export function cliSpec({ env = process.env, version = VERSION } = {}) {
15
- if (env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC)) return env.CORTAD_CLI_SPEC;
16
- return `cortad@${version}`;
17
+ const pinned = env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC) ? env.CORTAD_CLI_SPEC : null;
18
+ if (!pinned) return `cortad@${version}`;
19
+ const released = /^cortad@(\d+\.\d+\.\d+)$/.exec(pinned)?.[1];
20
+ return released && newer(version, released) ? `cortad@${version}` : pinned;
17
21
  }
22
+ const newer = (a, b) => {
23
+ if (!/^\d+\.\d+\.\d+$/.test(a)) return false;
24
+ const [x, y] = [a, b].map((v) => v.split(".").map(Number));
25
+ for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] > y[i];
26
+ return false;
27
+ };
18
28
 
19
29
  // npx needs -y to fetch a package without asking; a local folder is not fetched.
20
30
  export const npxArgs = (spec) => (isAbsolute(spec) ? [spec] : ["-y", spec]);
package/lib/stores.mjs CHANGED
@@ -232,7 +232,7 @@ const HELD = {
232
232
  "neon-key": (s) => ["there is no NEON_API_KEY in its settings to make a branch with", `Set NEON_API_KEY, or point ${s.names[0]} at a database on this machine`],
233
233
  neon: (s) => ["Neon did not make a branch of it", `Check NEON_API_KEY, or point ${s.names[0]} at a database on this machine`],
234
234
  supabase: (s) => ["a local Supabase needs supabase/config.toml, Docker and the supabase command", `Add them, or point ${s.names[0]} at a local Supabase`],
235
- engine: (s) => [`Cortad cannot copy a hosted ${ENGINE[s.engine]}`, `Point ${s.names[0]} at a ${ENGINE[s.engine]} on this machine`],
235
+ engine: (s) => [`no copy of a hosted ${ENGINE[s.engine]} is made`, `Point ${s.names[0]} at a ${ENGINE[s.engine]} on this machine`],
236
236
  parts: (s) => ["its settings name it in parts, which cannot be pointed at a copy", `Put its whole address in one setting, or point ${s.names[0]} at a database on this machine`],
237
237
  "no-migrations": (s) => ["no migrations were found to build a copy on this machine from", `Point ${s.names[0]} at a database on this machine`],
238
238
  "no-local": (s) => ["no Postgres on this machine let us make a database to build a copy in", "Start Postgres on this machine"],
@@ -247,8 +247,14 @@ const HELD = {
247
247
  // card told the owner, on the card, that "the owner can say yes to it on the card in the browser".
248
248
  const heldWhy = (s, why, tool) => {
249
249
  const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
250
- return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)} off this machine, and ${reason}, so the run would write into it as it is. ${fix}, then run the connect command again.`;
250
+ return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)} that is not on this machine, and ${reason}, so test conversations would save into it. ${fix}, then run the connect command again.`;
251
251
  };
252
+ // A Qdrant server the app writes into is kept by the hook in the app instead of by a copy made here:
253
+ // the first write a run makes into a collection copies that collection on the same server and the
254
+ // app's later calls go to the copy (lib/trace.cjs, lib/pyhook/sitecustomize.py). So a hosted Qdrant
255
+ // holds nothing, and a local one whose collection no setting names is not a risk either.
256
+ export const GUARDED_ENGINES = new Set(["qdrant"]);
257
+ const guardedKept = (s) => `Your app's ${ENGINE[s.engine]} collections at ${listed(s.names)} are not written into by a run: the first time a run writes into one, that collection is copied on the same server and the app's calls go to the copy, which is deleted when this command ends.`;
252
258
  const hostedKept = (s, got) => (got.how === "neon"
253
259
  ? `Your app runs against a Neon branch of its ${whatOf(s)}, made for this session through ${listed(s.names)}; the original is not touched, and the branch is deleted when this command ends and made again from the original at the next connect.`
254
260
  : got.how === "supabase"
@@ -259,7 +265,7 @@ export const storeId = (s) => createHash("sha256").update(s.key).digest("hex").s
259
265
  // A store off this machine the app was pointed at a copy of and reached anyway (dotenv's override):
260
266
  // trials write into the original, so it holds Run. `blocked`: the rows already holding it.
261
267
  export const bypassedOf = (connections, stores, blocked) => stores
262
- .filter((s) => !onThisMachine(s.host) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
268
+ .filter((s) => !onThisMachine(s.host) && !GUARDED_ENGINES.has(s.engine) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
263
269
  .map((s) => ({ id: storeId(s), names: s.names, why: heldWhy(s, "bypassed") }));
264
270
 
265
271
  // A copy that throws is one that did not go through, said as such; it never stops the app starting.
@@ -292,6 +298,12 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
292
298
  const pass = [];
293
299
  for (const s of stores) {
294
300
  if (done.has(s.key)) continue;
301
+ if (GUARDED_ENGINES.has(s.engine) && (!onThisMachine(s.host) || !s.points.length || !s.db)) {
302
+ if (await down(s)) continue;
303
+ done.add(s.key);
304
+ copies.push(guardedKept(s));
305
+ continue;
306
+ }
295
307
  if (!onThisMachine(s.host)) {
296
308
  done.add(s.key);
297
309
  const got = started ? await hosted.keep(s).catch(() => ({ why: ["other"] })) : { why: ["started"] };
@@ -329,6 +341,21 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
329
341
  for (const { s, row } of made.splice(0)) if (await drop(s, row.made, row.marker)) book.remove(row);
330
342
  await hosted.drop();
331
343
  },
344
+ // The collections the hook in the app copied for a run's writes (lib/trace.cjs copyOf), deleted
345
+ // with the key the settings hold for that server. Each is in the ledger from the moment it is
346
+ // seen, so one a killed session left is deleted by the next.
347
+ // ponytail: a server no setting names a key for is asked without one; a copy that refuses stays
348
+ // under its _cortad_ name for the person to see. The key the app sent never leaves the app.
349
+ async dropCopies(copies, stores) {
350
+ const rows = copies.map((c) => ({ kind: "qdrant-copy", key: c.base, made: c.clone }));
351
+ for (const row of rows) book.add(row);
352
+ const hostOf = (base) => { try { return new URL(base).hostname; } catch { return base; } };
353
+ for (const row of [...rows, ...book.orphans("qdrant-copy")]) {
354
+ // The hook names the server with its port and QDRANT_URL may not: the key is the host's.
355
+ const conn = { base: row.key, apiKey: stores.find((s) => s.engine === "qdrant" && s.conn?.base && hostOf(s.conn.base) === hostOf(row.key))?.conn.apiKey };
356
+ if (await dropQdrant(conn, row.made)) book.remove(row);
357
+ }
358
+ },
332
359
  };
333
360
  }
334
361
 
@@ -341,7 +368,7 @@ export function unnamedHeld(connections, stores, blocked) {
341
368
  for (const { host, port } of connections) {
342
369
  const local = onThisMachine(host);
343
370
  const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0] ?? ENGINE_AT[port];
344
- if (!engine || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
371
+ if (!engine || GUARDED_ENGINES.has(engine) || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
345
372
  const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
346
373
  if ([...blocked, ...rows].some((b) => b.id === id)) continue;
347
374
  // Named by its engine where a setting's name would stand: the API takes a name, never an address.