cortad 0.3.10 → 0.4.0
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/README.md +14 -7
- package/lib/auto-reach.mjs +6 -5
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +3 -4
- package/lib/env-files.mjs +39 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/files.mjs +5 -0
- package/lib/fresh.mjs +1 -1
- package/lib/intel-text.mjs +47 -0
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +38 -5
- package/lib/proc.mjs +3 -1
- package/lib/proof.mjs +8 -1
- package/lib/proxy.mjs +14 -3
- package/lib/pyhook/sitecustomize.py +1 -500
- package/lib/reach.mjs +7 -7
- package/lib/read-text.mjs +0 -1
- package/lib/replay.mjs +6 -11
- package/lib/start.mjs +13 -34
- package/lib/switches.mjs +9 -69
- package/lib/trace.cjs +2 -320
- package/lib/verbs.mjs +56 -1
- package/local.mjs +252 -520
- package/package.json +5 -15
- package/skill/SKILL.md +16 -9
- package/skill/references/results.md +5 -5
- package/lib/backing.mjs +0 -230
- package/lib/data.mjs +0 -137
- package/lib/hosted.mjs +0 -201
- package/lib/identities.mjs +0 -689
- package/lib/keeping.mjs +0 -174
- package/lib/ledger.mjs +0 -20
- package/lib/listing.mjs +0 -12
- package/lib/mint.mjs +0 -334
- package/lib/mongo.mjs +0 -74
- package/lib/pg.mjs +0 -118
- package/lib/qdrant.mjs +0 -48
- package/lib/redis.mjs +0 -130
- package/lib/service.mjs +0 -96
- package/lib/stores.mjs +0 -378
package/lib/stores.mjs
DELETED
|
@@ -1,378 +0,0 @@
|
|
|
1
|
-
// Your app's database servers, kept out of the run's way. A run creates people, conversations and
|
|
2
|
-
// records by the hundred; an app pointed at Postgres, Redis, MongoDB or a Qdrant collection kept
|
|
3
|
-
// every one of them. So each store the app's settings name on this machine is copied on its own
|
|
4
|
-
// server, under a name of ours, the app is started pointed at the copy through the same settings,
|
|
5
|
-
// and the copy is deleted when this command ends. The original is only ever read. What is not
|
|
6
|
-
// copied is said before a run: which store, and that what test conversations create stays in it. A store off
|
|
7
|
-
// this machine is copied where it can be (lib/hosted.mjs) and otherwise holds Run.
|
|
8
|
-
import { createHash } from "node:crypto";
|
|
9
|
-
import { hostedKeeper } from "./hosted.mjs";
|
|
10
|
-
import { ledger } from "./ledger.mjs";
|
|
11
|
-
import { cloneMongo, dropMongo, withDb } from "./mongo.mjs";
|
|
12
|
-
import { clonePg, dropPg } from "./pg.mjs";
|
|
13
|
-
import { cloneQdrant, dropQdrant } from "./qdrant.mjs";
|
|
14
|
-
import { cloneRedis, dropRedis } from "./redis.mjs";
|
|
15
|
-
|
|
16
|
-
export const ENGINE = { postgres: "Postgres", mysql: "MySQL", mongo: "MongoDB", redis: "Redis", qdrant: "Qdrant", chroma: "Chroma", milvus: "Milvus", weaviate: "Weaviate", elasticsearch: "Elasticsearch", meilisearch: "Meilisearch", typesense: "Typesense", neo4j: "Neo4j", sqlserver: "SQL Server", supabase: "Supabase", pinecone: "Pinecone", firestore: "Firestore" };
|
|
17
|
-
// Where each listens when nothing else is said. Also how a connection the hook saw is named.
|
|
18
|
-
export const PORT_OF = { postgres: 5432, mysql: 3306, mongo: 27017, redis: 6379, qdrant: 6333, chroma: 8000, milvus: 19530, weaviate: 8080, elasticsearch: 9200, meilisearch: 7700, typesense: 8108, neo4j: 7687, sqlserver: 1433 };
|
|
19
|
-
// A connection is named by its port only where the port is the store's own: 8000 and 8080 are
|
|
20
|
-
// every other web server's too.
|
|
21
|
-
const ENGINE_AT = { ...Object.fromEntries(Object.entries(PORT_OF).filter(([e]) => e !== "chroma" && e !== "weaviate").map(([e, p]) => [p, e])), 6334: "qdrant" };
|
|
22
|
-
export const isStorePort = (port) => Boolean(ENGINE_AT[port]);
|
|
23
|
-
// Stores reached through their maker's SDK, which a setting rarely names: the hook sees the host.
|
|
24
|
-
const HOSTED = [["supabase", /\.supabase\.(?:co|in|com)$/], ["mongo", /\.mongodb\.net$/], ["elasticsearch", /\.elastic-cloud\.com$|\.found\.io$|\.es\.amazonaws\.com$/], ["redis", /\.upstash\.io$|\.redis-cloud\.com$|\.redislabs\.com$/], ["postgres", /\.neon\.tech$/], ["pinecone", /\.pinecone\.io$/], ["qdrant", /\.qdrant\.io$/], ["firestore", /^firestore\.googleapis\.com$/]];
|
|
25
|
-
// How each store we copy is copied and dropped: on its own server, under the name given.
|
|
26
|
-
const COPY = {
|
|
27
|
-
postgres: { copy: (s, clone) => clonePg(s.conn, s.db, clone), drop: (s, clone) => dropPg(s.conn, clone) },
|
|
28
|
-
redis: { copy: (s, clone, marker) => cloneRedis(s.conn, s.db, marker), drop: (s, clone, marker) => dropRedis(s.conn, clone, marker) },
|
|
29
|
-
mongo: { copy: (s, clone) => cloneMongo(s.conn, s.db, clone), drop: (s, clone) => dropMongo(s.conn, clone, s.db) },
|
|
30
|
-
qdrant: { copy: (s, clone) => cloneQdrant(s.conn, s.db, clone), drop: (s, clone) => dropQdrant(s.conn, clone) },
|
|
31
|
-
};
|
|
32
|
-
const TOOLS = { postgres: "Postgres's own psql and pg_dump are", mongo: "MongoDB's own mongodump, mongorestore and mongosh are" };
|
|
33
|
-
|
|
34
|
-
const SCHEME = /^(postgres(?:ql)?|mysql|mariadb|mongodb|rediss?|valkeys?|sqlserver|mssql)((?:\+\w+)*):\/\/([^/?#]*)(\/[^?#]*)?([?#].*)?$/i;
|
|
35
|
-
const SCHEME_ENGINE = { postgres: "postgres", postgresql: "postgres", mysql: "mysql", mariadb: "mysql", mongodb: "mongo", redis: "redis", rediss: "redis", valkey: "redis", valkeys: "redis", sqlserver: "sqlserver", mssql: "sqlserver" };
|
|
36
|
-
// A search or vector store is named by what its setting is called, since it speaks plain HTTP.
|
|
37
|
-
const BY_NAME = [["qdrant", /QDRANT/], ["chroma", /CHROMA/], ["milvus", /MILVUS|ZILLIZ/], ["weaviate", /WEAVIATE/], ["elasticsearch", /ELASTIC|OPENSEARCH/], ["meilisearch", /MEILI/], ["typesense", /TYPESENSE/], ["neo4j", /NEO4J/], ["supabase", /SUPABASE/]];
|
|
38
|
-
const WORD_ENGINE = [["postgres", /^(?:PG|POSTGRES|PSQL)|POSTGRES/], ["mysql", /MYSQL|MARIA/], ["mongo", /MONGO/], ["redis", /REDIS|VALKEY/]];
|
|
39
|
-
const SECRETISH = /(?:^|_)(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD)$/;
|
|
40
|
-
// Every engine's word in a setting's name, so a setting that carries one is that engine's own.
|
|
41
|
-
const ENGINE_WORD = new RegExp([...Object.keys(ENGINE), ...[...BY_NAME, ...WORD_ENGINE].map(([, re]) => re.source)].join("|"), "i");
|
|
42
|
-
// A settings file's values without the ones that name a store. An app the person started with
|
|
43
|
-
// their own command is handed the rest: which databases it uses is that command's to say.
|
|
44
|
-
const STORE_SETTING = new RegExp(`${ENGINE_WORD.source}|(?:^|_)(?:DB|DATABASE)(?:_|$)`, "i");
|
|
45
|
-
export const withoutStoreSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => !STORE_SETTING.test(name)));
|
|
46
|
-
// Stores that hold no collections: a bare COLLECTION setting beside one of them is still Qdrant's.
|
|
47
|
-
const NO_COLLECTIONS = new Set(["redis", "mysql", "sqlserver", "neo4j", "meilisearch", "pinecone"]);
|
|
48
|
-
const LOOPBACK = /^(?:localhost|127(?:\.\d+){3}|::1|\[::1\]|0\.0\.0\.0|host\.docker\.internal)?$/i;
|
|
49
|
-
// A socket path (host=/var/run/postgresql) is this machine too.
|
|
50
|
-
export const onThisMachine = (host) => LOOPBACK.test(String(host ?? "")) || String(host).startsWith("/");
|
|
51
|
-
// A Neon endpoint's pooled host is the same database as its direct one.
|
|
52
|
-
const hostKey = (host) => (onThisMachine(host) ? "localhost" : String(host).toLowerCase().replace(/^(ep-[a-z0-9-]+?)-pooler\./, "$1."));
|
|
53
|
-
const at = (s) => `${s.engine}|${hostKey(s.host)}|${s.port}`;
|
|
54
|
-
|
|
55
|
-
// A part that is no percent-encoding (%DB%) is kept as its maker wrote it.
|
|
56
|
-
const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
|
|
57
|
-
const authority = (a) => {
|
|
58
|
-
const cut = a.lastIndexOf("@");
|
|
59
|
-
const cred = cut >= 0 ? a.slice(0, cut) : "";
|
|
60
|
-
// A replica set lists its hosts; the first answers for all of them.
|
|
61
|
-
const hostPort = (cut >= 0 ? a.slice(cut + 1) : a).split(",")[0];
|
|
62
|
-
const m = /^(\[[^\]]*\]|[^:]*)(?::(\d+))?$/.exec(hostPort);
|
|
63
|
-
if (!m) return null;
|
|
64
|
-
const [user, ...pass] = cred ? cred.split(":") : [];
|
|
65
|
-
return { host: m[1].replace(/^\[|\]$/g, ""), port: m[2] ? Number(m[2]) : null, user: user ? dec(user) : "", password: pass.length ? dec(pass.join(":")) : "" };
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
// A template the code fills (postgresql://%s:%s@localhost/%s, {}:{}) names no user or database of
|
|
69
|
-
// its own: tried, it would be refused for a password the settings never carried.
|
|
70
|
-
const TEMPLATE = /%s|%\(\w+\)s|\$\{\w+|\{\w*\}/;
|
|
71
|
-
|
|
72
|
-
// One setting that is a whole address: postgresql+asyncpg://u:p@localhost:5432/app, redis://localhost/2.
|
|
73
|
-
function fromUrl(name, value) {
|
|
74
|
-
const m = SCHEME.exec(value);
|
|
75
|
-
if (!m || TEMPLATE.test(value)) return null;
|
|
76
|
-
const engine = SCHEME_ENGINE[m[1].toLowerCase()];
|
|
77
|
-
const a = authority(m[3]);
|
|
78
|
-
if (!a) return null;
|
|
79
|
-
const query = new URLSearchParams((m[5] ?? "").replace(/^[?#]/, ""));
|
|
80
|
-
const path = dec((m[4] ?? "").replace(/^\//, ""));
|
|
81
|
-
const db = engine === "redis" ? Number(path || 0) : path;
|
|
82
|
-
const host = a.host || query.get("host") || "";
|
|
83
|
-
const port = a.port ?? PORT_OF[engine] ?? null;
|
|
84
|
-
return {
|
|
85
|
-
engine, host, port, db,
|
|
86
|
-
conn: { host, port, user: a.user, password: a.password, sslmode: query.get("sslmode") ?? "", tlsOn: /^(?:rediss|valkeys)$/i.test(m[1]), uri: value },
|
|
87
|
-
point: engine === "mongo" ? (to) => ({ [name]: withDb(value, to, db) }) : (to) => ({ [name]: `${m[1]}${m[2]}://${m[3]}/${encodeURIComponent(String(to))}${m[5] ?? ""}` }),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// A search or vector store's address: QDRANT_URL=http://localhost:6333, MILVUS_URI=localhost:19530.
|
|
92
|
-
function fromNamed(name, value) {
|
|
93
|
-
const engine = BY_NAME.find(([, re]) => re.test(name))?.[0];
|
|
94
|
-
if (!engine || SECRETISH.test(name)) return null;
|
|
95
|
-
const m = /^(?:(https?|grpc|bolt|neo4j):\/\/)?([\w.-]+|\[[:\w]+\]):?(\d+)?(?:[/?#].*)?$/i.exec(value);
|
|
96
|
-
if (!m || (!m[1] && !m[3])) return null;
|
|
97
|
-
const host = m[2].replace(/^\[|\]$/g, "");
|
|
98
|
-
const port = m[3] ? Number(m[3]) : m[1] === "https" ? 443 : PORT_OF[engine] ?? 80;
|
|
99
|
-
return { engine, host, port, db: null, conn: { base: `${m[1] === "https" ? "https" : "http"}://${host.includes(":") ? `[${host}]` : host}:${port}` } };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// A vector store holds collections, and the app's settings name the ones it uses: QDRANT_COLLECTION,
|
|
103
|
-
// COLLECTION_NAME. Each is its own store, pointed at its copy through that setting. A setting that
|
|
104
|
-
// carries another engine's word (MONGODB_COLLECTION) is that engine's, and a bare one is this
|
|
105
|
-
// store's only when no other store the settings name could own it. The key is the setting beside
|
|
106
|
-
// it that ends in KEY.
|
|
107
|
-
function withCollections(store, values, others) {
|
|
108
|
-
const word = new RegExp(store.engine, "i");
|
|
109
|
-
const shared = others.some((e) => e !== store.engine && !NO_COLLECTIONS.has(e));
|
|
110
|
-
const key = Object.keys(values).find((n) => word.test(n) && /KEY$/.test(n) && values[n]);
|
|
111
|
-
// QDRANT_HOST and QDRANT_PORT name the same server QDRANT_URL does.
|
|
112
|
-
const base = store.conn.base ?? `http://${store.host.includes(":") ? `[${store.host}]` : store.host}:${store.port}`;
|
|
113
|
-
const conn = { ...store.conn, base, ...(key ? { apiKey: values[key] } : {}) };
|
|
114
|
-
const named = Object.keys(values).filter((n) => /COLLECTION/.test(n) && !SECRETISH.test(n) && String(values[n] ?? "").trim() && (word.test(n) || (!ENGINE_WORD.test(n) && !shared)));
|
|
115
|
-
if (!named.length) return [{ ...store, conn }];
|
|
116
|
-
return named.map((n) => ({ ...store, conn, db: String(values[n]).trim(), names: [...store.names, n], point: (to) => ({ [n]: String(to) }) }));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Settings that name one store in parts, grouped by what they share before the part:
|
|
120
|
-
// POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB; DB_HOST, DB_NAME, DB_CONNECTION=pgsql; PGHOST, PGDATABASE.
|
|
121
|
-
const HOST_PART = /^(.+?)_?(?:HOST(?:NAME)?|SERVER)$/;
|
|
122
|
-
const PARTS = { port: /^PORT$/, user: /^USER(?:NAME)?$/, password: /^(?:PASS(?:WORD)?|PWD)$/, db: /^(?:DB|DATABASE|DBNAME|DB_?NAME|DATABASE_?NAME|NAME|INDEX)$/, kind: /^(?:ENGINE|DRIVER|DIALECT|CONNECTION|TYPE|CLIENT)$/ };
|
|
123
|
-
const KIND_ENGINE = [["postgres", /pg|postgres/], ["mysql", /mysql|maria/], ["mongo", /mongo/], ["redis", /redis|valkey/]];
|
|
124
|
-
function fromParts(values) {
|
|
125
|
-
const hosts = new Map();
|
|
126
|
-
for (const name of Object.keys(values)) { const m = HOST_PART.exec(name); if (m && !hosts.has(m[1])) hosts.set(m[1], name); }
|
|
127
|
-
// libpq's own settings need no host: without one it is the local socket.
|
|
128
|
-
if (!hosts.has("PG") && ["PGDATABASE", "PGUSER", "PGPORT"].some((n) => values[n])) hosts.set("PG", null);
|
|
129
|
-
const out = [];
|
|
130
|
-
for (const [p, hostName] of hosts) {
|
|
131
|
-
// A host setting left blank is a store the app is set up not to use (QDRANT_HOST= beside QDRANT_PATH).
|
|
132
|
-
if (hostName && !String(values[hostName] ?? "").trim()) continue;
|
|
133
|
-
// A host setting that holds a whole address (REDIS_HOST=redis://...) is fromUrl's, not a host.
|
|
134
|
-
if (hostName && String(values[hostName]).includes("://")) continue;
|
|
135
|
-
const parts = {};
|
|
136
|
-
for (const name of Object.keys(values)) {
|
|
137
|
-
if (!name.startsWith(p) || name === hostName) continue;
|
|
138
|
-
const rest = name.slice(p.length).replace(/^_/, "");
|
|
139
|
-
const part = Object.keys(PARTS).find((k) => PARTS[k].test(rest));
|
|
140
|
-
if (part && !parts[part]) parts[part] = name;
|
|
141
|
-
}
|
|
142
|
-
const [host = "", inline] = String(hostName ? values[hostName] : "").split(":");
|
|
143
|
-
const named = Number(values[parts.port] || inline) || null;
|
|
144
|
-
const kind = String(values[parts.kind] ?? "").toLowerCase();
|
|
145
|
-
const engine = WORD_ENGINE.find(([, re]) => re.test(p))?.[0] ?? KIND_ENGINE.find(([, re]) => re.test(kind))?.[0] ?? ENGINE_AT[named];
|
|
146
|
-
if (!engine) continue;
|
|
147
|
-
const port = named ?? PORT_OF[engine];
|
|
148
|
-
const db = parts.db ? (engine === "redis" ? Number(values[parts.db] || 0) : values[parts.db]) : engine === "redis" ? 0 : "";
|
|
149
|
-
out.push({
|
|
150
|
-
engine, host, port, db,
|
|
151
|
-
conn: { host, port, user: values[parts.user] ?? "", password: values[parts.password] ?? "", sslmode: p === "PG" ? values.PGSSLMODE ?? "" : "" },
|
|
152
|
-
names: [hostName ?? parts.user ?? parts.port, parts.db].filter(Boolean),
|
|
153
|
-
// Only a setting that holds the database can point the app at another one.
|
|
154
|
-
point: parts.db ? (to) => ({ [parts.db]: String(to) }) : null,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Every store the app's settings name, one entry per database however many settings name it
|
|
161
|
-
// (DATABASE_URL and DIRECT_URL, or DATABASE_URL beside POSTGRES_HOST and POSTGRES_DB). A setting
|
|
162
|
-
// that names the server but no database (POSTGRES_USER for a container's first start) is the same
|
|
163
|
-
// store as the one database its settings do name there.
|
|
164
|
-
export function storesOf(values) {
|
|
165
|
-
const found = [];
|
|
166
|
-
for (const [name, raw] of Object.entries(values)) {
|
|
167
|
-
const value = String(raw ?? "").trim();
|
|
168
|
-
if (!value) continue;
|
|
169
|
-
const url = fromUrl(name, value);
|
|
170
|
-
if (url) { found.push({ ...url, names: [name] }); continue; }
|
|
171
|
-
const named = fromNamed(name, value);
|
|
172
|
-
if (named) found.push({ ...named, names: [name], point: null });
|
|
173
|
-
}
|
|
174
|
-
found.push(...fromParts(values));
|
|
175
|
-
const engines = found.map((s) => s.engine);
|
|
176
|
-
const each = found.flatMap((s) => (s.engine === "qdrant" ? withCollections(s, values, engines) : [s]));
|
|
177
|
-
const byKey = new Map();
|
|
178
|
-
const merge = (held, s) => {
|
|
179
|
-
held.names = [...new Set([...held.names, ...s.names])];
|
|
180
|
-
if (s.point) held.points.push(s.point);
|
|
181
|
-
held.conn ??= s.conn;
|
|
182
|
-
};
|
|
183
|
-
const whole = each.filter((s) => s.db !== "" && s.db !== null);
|
|
184
|
-
for (const s of [...whole, ...each.filter((x) => !whole.includes(x))]) {
|
|
185
|
-
const key = `${at(s)}|${s.db ?? ""}`;
|
|
186
|
-
const held = byKey.get(key) ?? (s.db === "" || s.db === null ? [...byKey.values()].find((h) => at(h) === at(s)) : undefined);
|
|
187
|
-
if (held) { merge(held, s); continue; }
|
|
188
|
-
byKey.set(key, { ...s, key, points: s.point ? [s.point] : [] });
|
|
189
|
-
}
|
|
190
|
-
return [...byKey.values()];
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// At most three by name, so a sentence stays one sentence.
|
|
194
|
-
const listed = (names) => (names.length === 1 ? names[0] : names.length <= 3 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more settings`);
|
|
195
|
-
const unit = (s) => (s.engine === "qdrant" ? "collection" : "database");
|
|
196
|
-
const whatOf = (s) => (s.engine === "redis" ? `Redis database ${s.db}` : s.db ? `${ENGINE[s.engine]} ${unit(s)} ${s.db}` : `${ENGINE[s.engine]} server`);
|
|
197
|
-
const copied = Object.keys(COPY).map((e) => ENGINE[e]);
|
|
198
|
-
const copiedList = `${copied.slice(0, -1).join(", ")} and ${copied.at(-1)}`;
|
|
199
|
-
|
|
200
|
-
// Why no copy was made, in a sentence of theirs, and what to do about it. The store's own words are
|
|
201
|
-
// never quoted: they name users and hosts.
|
|
202
|
-
const REASONS = {
|
|
203
|
-
auth: ["the password its settings carry was refused", "Check that setting before a run"],
|
|
204
|
-
rights: ["the user it signs in as may not create databases", "Give that user the right to create databases, or point it at a scratch database, before a run"],
|
|
205
|
-
down: ["nothing answered at the address its settings give", "Start it before a run"],
|
|
206
|
-
big: ["it is over the size a run copies", "Point it at a smaller scratch copy before a run if it holds real data"],
|
|
207
|
-
missing: [(s) => `${TOOLS[s.engine]} not on this machine's PATH`, (s) => `Install them, or point it at a scratch ${unit(s)}, before a run`],
|
|
208
|
-
version: ["this machine's copy of the store's own tools is older than the server", "Update them, or point it at a scratch database, before a run"],
|
|
209
|
-
oldServer: ["the server is older than Redis 6.2, which a copy needs", "Upgrade it, or point it at a scratch database, before a run"],
|
|
210
|
-
full: ["it has no empty database left to copy into", "Point it at a scratch database before a run if it holds real data"],
|
|
211
|
-
cluster: ["it runs as a cluster, which has one database only", "Point it at a scratch server before a run if it holds real data"],
|
|
212
|
-
other: ["the copy did not go through", "Point it at a scratch database before a run if it holds real data"],
|
|
213
|
-
};
|
|
214
|
-
const risk = (s, why) => {
|
|
215
|
-
const lead = `What test conversations create in the ${whatOf(s)} that ${listed(s.names)} ${s.names.length === 1 ? "names" : "name"} stays there`;
|
|
216
|
-
if (why === "started") return `${lead}: your app was already running when this command started. Stop it and run the command again to run against a copy.`;
|
|
217
|
-
if (why === "engine") return `${lead}: we copy ${copiedList} for a run, not ${ENGINE[s.engine]}. Point ${s.names[0]} at a scratch ${s.db ? unit(s) : "server"} before a run if that one holds real data.`;
|
|
218
|
-
if (why === "fixed" && s.engine === "qdrant") return `${lead}: its settings do not say which collection the app keeps there, so the app cannot be pointed at a copy. Point ${s.names[0]} at a scratch server before a run if that one holds real data.`;
|
|
219
|
-
if (why === "fixed") return `${lead}: the ${s.engine === "redis" ? "database number" : `${unit(s)} name`} is not among its settings, so the app cannot be pointed at a copy. Put it in one before a run if that one holds real data.`;
|
|
220
|
-
const [reason, fix] = (REASONS[why] ?? REASONS.other).map((w) => (typeof w === "function" ? w(s) : w));
|
|
221
|
-
return `${lead}: ${reason}, so no copy was made. ${fix}.`;
|
|
222
|
-
};
|
|
223
|
-
const kept = (s, clone, how) => (s.engine === "redis"
|
|
224
|
-
? `Your app runs against a copy of its Redis data in database ${clone} of the same server, through ${listed(s.names)}; database ${s.db} is not touched, and database ${clone} is emptied when this command ends and copied again at the next connect.`
|
|
225
|
-
: how === "empty"
|
|
226
|
-
? `Your app makes its ${ENGINE[s.engine]} ${unit(s)} ${s.db} under the name ${clone} on the same server, through ${listed(s.names)}; the server holds no ${s.db} yet, and ${clone} is deleted when this command ends and made again empty at the next connect.`
|
|
227
|
-
: `Your app runs against a copy of its ${ENGINE[s.engine]} ${unit(s)} ${s.db}, made on the same server, through ${listed(s.names)}; the original is not touched, and the copy is deleted when this command ends and made again from the original at the next connect.`);
|
|
228
|
-
|
|
229
|
-
// What holds Run for a store off this machine no copy could be made of, and the one thing to do.
|
|
230
|
-
// The first reason is the most useful: a Neon store with no key says the key.
|
|
231
|
-
const HELD = {
|
|
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
|
-
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
|
-
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) => [`no copy of a hosted ${ENGINE[s.engine]} is made`, `Point ${s.names[0]} at a ${ENGINE[s.engine]} on this machine`],
|
|
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
|
-
"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
|
-
"no-local": (s) => ["no Postgres on this machine let us make a database to build a copy in", "Start Postgres on this machine"],
|
|
239
|
-
migrations: (s, tool) => [`its ${tool} migrations did not run against a copy on this machine`, `Run them against a local database once by hand, or point ${s.names[0]} at one`],
|
|
240
|
-
started: () => ["your app was already running when this command started", "Stop your app"],
|
|
241
|
-
bypassed: () => ["your app reached it anyway, since it loads its settings file over the settings it was started with", "Load that file without overriding what is already set"],
|
|
242
|
-
other: (s) => ["the copy did not go through", `Point ${s.names[0]} at a database on this machine`],
|
|
243
|
-
};
|
|
244
|
-
// The fact and the way to keep the store out of the run, read by the person on the card and by their
|
|
245
|
-
// agent in status. The other way, the owner's yes, is each surface's own to offer: the card as its
|
|
246
|
-
// button, status in the sentence after this one (src/local/contact.ts heldLine). Written in here, the
|
|
247
|
-
// card told the owner, on the card, that "the owner can say yes to it on the card in the browser".
|
|
248
|
-
const heldWhy = (s, why, tool) => {
|
|
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)} that is not on this machine, and ${reason}, so test conversations would save into it. ${fix}, then run the connect command again.`;
|
|
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.`;
|
|
258
|
-
const hostedKept = (s, got) => (got.how === "neon"
|
|
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.`
|
|
260
|
-
: got.how === "supabase"
|
|
261
|
-
? `Your app runs against a Supabase on this machine started from supabase/config.toml, through ${listed(s.names)}; the hosted one is not touched, and what test conversations create in the local one stays there${got.started ? ", and it is stopped when this command ends" : ""}.`
|
|
262
|
-
: `Your app runs against an empty ${ENGINE[s.engine]} database on this machine built by its own ${got.tool} migrations, through ${listed(s.names)}; the hosted one is not touched, and the copy is deleted when this command ends and built again empty at the next connect.`);
|
|
263
|
-
// How the person's yes names a store off this machine: never its address.
|
|
264
|
-
export const storeId = (s) => createHash("sha256").update(s.key).digest("hex").slice(0, 16);
|
|
265
|
-
// A store off this machine the app was pointed at a copy of and reached anyway (dotenv's override):
|
|
266
|
-
// trials write into the original, so it holds Run. `blocked`: the rows already holding it.
|
|
267
|
-
export const bypassedOf = (connections, stores, blocked) => stores
|
|
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)))
|
|
269
|
-
.map((s) => ({ id: storeId(s), names: s.names, why: heldWhy(s, "bypassed") }));
|
|
270
|
-
|
|
271
|
-
// A copy that throws is one that did not go through, said as such; it never stops the app starting.
|
|
272
|
-
const drop = (s, clone, marker) => COPY[s.engine].drop(s, clone, marker).catch(() => false);
|
|
273
|
-
const copy = (s, clone, marker) => COPY[s.engine].copy(s, clone, marker).catch(() => ({ why: "other" }));
|
|
274
|
-
const cloneName = (db) => `${String(db).slice(0, 40)}_cortad_${process.pid}`;
|
|
275
|
-
|
|
276
|
-
// One session's copies. keep() copies the stores given that answer and says what it did; a store
|
|
277
|
-
// nothing answers at yet is left out, unsaid, since trials cannot write into it: once it is started
|
|
278
|
-
// (lib/backing.mjs), keep() is asked again for it. drop() deletes every copy, and copies a session
|
|
279
|
-
// that died left behind are deleted by the next one, through the ledger. A store off this machine
|
|
280
|
-
// with no copy is `blocked`, and `pass` names the hosts of the copies made off it.
|
|
281
|
-
// `values`, `dirs`, `fetchFn` and `local` are lib/hosted.mjs's.
|
|
282
|
-
export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local }) {
|
|
283
|
-
const book = ledger(ledgerFile);
|
|
284
|
-
const hosted = hostedKeeper({ values, dirs, book, fetchFn, local });
|
|
285
|
-
const made = [];
|
|
286
|
-
const done = new Set();
|
|
287
|
-
return {
|
|
288
|
-
async keep(stores, { started = true, down = () => false } = {}) {
|
|
289
|
-
for (const row of book.orphans("copy")) {
|
|
290
|
-
const s = stores.find((x) => x.key === row.key);
|
|
291
|
-
if (s && (await drop(s, row.made, row.marker))) book.remove(row);
|
|
292
|
-
}
|
|
293
|
-
await hosted.orphans();
|
|
294
|
-
const env = {};
|
|
295
|
-
const risks = [];
|
|
296
|
-
const copies = [];
|
|
297
|
-
const blocked = [];
|
|
298
|
-
const pass = [];
|
|
299
|
-
for (const s of stores) {
|
|
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
|
-
}
|
|
307
|
-
if (!onThisMachine(s.host)) {
|
|
308
|
-
done.add(s.key);
|
|
309
|
-
const got = started ? await hosted.keep(s).catch(() => ({ why: ["other"] })) : { why: ["started"] };
|
|
310
|
-
if (!got.env) { blocked.push({ id: storeId(s), names: s.names, why: heldWhy(s, got.why[0], got.tool) }); continue; }
|
|
311
|
-
Object.assign(env, got.env);
|
|
312
|
-
pass.push(...got.pass);
|
|
313
|
-
copies.push(hostedKept(s, got));
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
if (await down(s)) continue;
|
|
317
|
-
done.add(s.key);
|
|
318
|
-
if (!started) { risks.push(risk(s, "started")); continue; }
|
|
319
|
-
if (!COPY[s.engine]) { risks.push(risk(s, "engine")); continue; }
|
|
320
|
-
if (!s.points.length || (s.engine !== "redis" && !s.db)) { risks.push(risk(s, "fixed")); continue; }
|
|
321
|
-
const marker = `${process.pid}-${Date.now()}`;
|
|
322
|
-
// Recorded before the copy starts, so a session killed mid-copy leaves a row the next one
|
|
323
|
-
// undoes. A Redis copy's database is found by its marker then.
|
|
324
|
-
const row = { kind: "copy", key: s.key, made: s.engine === "redis" ? null : cloneName(s.db), marker };
|
|
325
|
-
book.add(row);
|
|
326
|
-
const got = await copy(s, cloneName(s.db), marker);
|
|
327
|
-
if (got.why) {
|
|
328
|
-
if (await drop(s, row.made, marker)) book.remove(row);
|
|
329
|
-
// A database the server does not hold is one trials cannot write into.
|
|
330
|
-
if (got.why !== "gone") risks.push(risk(s, got.why));
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
const clone = got.index ?? cloneName(s.db);
|
|
334
|
-
made.push({ s, row });
|
|
335
|
-
for (const point of s.points) Object.assign(env, point(clone));
|
|
336
|
-
copies.push(kept(s, clone, got.how));
|
|
337
|
-
}
|
|
338
|
-
return { env, risks, copies, blocked, pass };
|
|
339
|
-
},
|
|
340
|
-
drop: async () => {
|
|
341
|
-
for (const { s, row } of made.splice(0)) if (await drop(s, row.made, row.marker)) book.remove(row);
|
|
342
|
-
await hosted.drop();
|
|
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
|
-
},
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// The stores the app reached that no setting names, as the hook saw each connection: a store written
|
|
363
|
-
// into a config file rather than the environment, one its maker's SDK finds by itself, or the ones an
|
|
364
|
-
// app started with the person's own command was given. No copy stands in for any of them, so each
|
|
365
|
-
// holds Run for the person's yes, once. `blocked`: the rows already holding it.
|
|
366
|
-
export function unnamedHeld(connections, stores, blocked) {
|
|
367
|
-
const rows = [];
|
|
368
|
-
for (const { host, port } of connections) {
|
|
369
|
-
const local = onThisMachine(host);
|
|
370
|
-
const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0] ?? ENGINE_AT[port];
|
|
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;
|
|
372
|
-
const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
|
|
373
|
-
if ([...blocked, ...rows].some((b) => b.id === id)) continue;
|
|
374
|
-
// Named by its engine where a setting's name would stand: the API takes a name, never an address.
|
|
375
|
-
rows.push({ id, names: [ENGINE[engine].replace(/\W+/g, "_")], why: `Your app reaches ${/^[AEIOU]/.test(ENGINE[engine]) ? "an" : "a"} ${ENGINE[engine]} server ${local ? "on" : "off"} this machine that it was not given a copy of, so the run would write what its test conversations create into it as it is. Start your app on a copy of it, then run the connect command again.` });
|
|
376
|
-
}
|
|
377
|
-
return rows;
|
|
378
|
-
}
|