cortad 0.3.10 → 0.3.11

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/lib/stores.mjs CHANGED
@@ -1,14 +1,17 @@
1
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.
2
+ // records by the hundred; an app pointed at Postgres, Redis, MongoDB, Elasticsearch or a Qdrant
3
+ // collection kept every one of them. So each store the app's settings name on this machine is copied
4
+ // on its own server under a name of ours, or, where the app names its databases in its code, as a
5
+ // whole server in a container of ours (lib/container.mjs); the app is started pointed at the copy
6
+ // through the same settings, and the copy is deleted when this command ends. The original is only
7
+ // ever read. A store no copy is made of holds Run until the person says yes to writing into it. A
8
+ // store off this machine is copied where it can be (lib/hosted.mjs) and otherwise holds Run.
8
9
  import { createHash } from "node:crypto";
10
+ import { copyName, removeCopy } from "./container.mjs";
11
+ import { serveElastic } from "./elastic.mjs";
9
12
  import { hostedKeeper } from "./hosted.mjs";
10
13
  import { ledger } from "./ledger.mjs";
11
- import { cloneMongo, dropMongo, withDb } from "./mongo.mjs";
14
+ import { cloneMongo, dropMongo, serveMongo, withDb } from "./mongo.mjs";
12
15
  import { clonePg, dropPg } from "./pg.mjs";
13
16
  import { cloneQdrant, dropQdrant } from "./qdrant.mjs";
14
17
  import { cloneRedis, dropRedis } from "./redis.mjs";
@@ -29,12 +32,14 @@ const COPY = {
29
32
  mongo: { copy: (s, clone) => cloneMongo(s.conn, s.db, clone), drop: (s, clone) => dropMongo(s.conn, clone, s.db) },
30
33
  qdrant: { copy: (s, clone) => cloneQdrant(s.conn, s.db, clone), drop: (s, clone) => dropQdrant(s.conn, clone) },
31
34
  };
35
+ // Stores whose app names its databases in its code are copied as a whole server instead.
36
+ const SERVER = { mongo: (s, name) => serveMongo(s.conn, name), elasticsearch: (s, name) => serveElastic(s.conn, name) };
32
37
  const TOOLS = { postgres: "Postgres's own psql and pg_dump are", mongo: "MongoDB's own mongodump, mongorestore and mongosh are" };
33
38
 
34
39
  const SCHEME = /^(postgres(?:ql)?|mysql|mariadb|mongodb|rediss?|valkeys?|sqlserver|mssql)((?:\+\w+)*):\/\/([^/?#]*)(\/[^?#]*)?([?#].*)?$/i;
35
40
  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
41
  // 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/]];
42
+ const BY_NAME = [["qdrant", /QDRANT/], ["chroma", /CHROMA/], ["milvus", /MILVUS|ZILLIZ/], ["weaviate", /WEAVIATE/], ["elasticsearch", /ELASTIC|OPENSEARCH|^ES_/], ["meilisearch", /MEILI/], ["typesense", /TYPESENSE/], ["neo4j", /NEO4J/], ["supabase", /SUPABASE/]];
38
43
  const WORD_ENGINE = [["postgres", /^(?:PG|POSTGRES|PSQL)|POSTGRES/], ["mysql", /MYSQL|MARIA/], ["mongo", /MONGO/], ["redis", /REDIS|VALKEY/]];
39
44
  const SECRETISH = /(?:^|_)(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD)$/;
40
45
  // Every engine's word in a setting's name, so a setting that carries one is that engine's own.
@@ -43,6 +48,7 @@ const ENGINE_WORD = new RegExp([...Object.keys(ENGINE), ...[...BY_NAME, ...WORD_
43
48
  // their own command is handed the rest: which databases it uses is that command's to say.
44
49
  const STORE_SETTING = new RegExp(`${ENGINE_WORD.source}|(?:^|_)(?:DB|DATABASE)(?:_|$)`, "i");
45
50
  export const withoutStoreSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => !STORE_SETTING.test(name)));
51
+ export const storeSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => STORE_SETTING.test(name)));
46
52
  // Stores that hold no collections: a bare COLLECTION setting beside one of them is still Qdrant's.
47
53
  const NO_COLLECTIONS = new Set(["redis", "mysql", "sqlserver", "neo4j", "meilisearch", "pinecone"]);
48
54
  const LOOPBACK = /^(?:localhost|127(?:\.\d+){3}|::1|\[::1\]|0\.0\.0\.0|host\.docker\.internal)?$/i;
@@ -85,18 +91,38 @@ function fromUrl(name, value) {
85
91
  engine, host, port, db,
86
92
  conn: { host, port, user: a.user, password: a.password, sslmode: query.get("sslmode") ?? "", tlsOn: /^(?:rediss|valkeys)$/i.test(m[1]), uri: value },
87
93
  point: engine === "mongo" ? (to) => ({ [name]: withDb(value, to, db) }) : (to) => ({ [name]: `${m[1]}${m[2]}://${m[3]}/${encodeURIComponent(String(to))}${m[5] ?? ""}` }),
94
+ // The same address on another server: what a copy of the whole server is reached through.
95
+ move: (to, toPort) => ({ [name]: `${m[1]}${m[2]}://${m[3].slice(0, m[3].lastIndexOf("@") + 1)}${to}:${toPort}${m[4] ?? ""}${m[5] ?? ""}` }),
88
96
  };
89
97
  }
90
98
 
91
- // A search or vector store's address: QDRANT_URL=http://localhost:6333, MILVUS_URI=localhost:19530.
99
+ // A search or vector store's address: QDRANT_URL=http://localhost:6333, MILVUS_URI=localhost:19530,
100
+ // ELASTICSEARCH_URL=http://elastic:pw@localhost:9200.
92
101
  function fromNamed(name, value) {
93
102
  const engine = BY_NAME.find(([, re]) => re.test(name))?.[0];
94
103
  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}` } };
104
+ const m = /^(?:(https?|grpc|bolt|neo4j):\/\/)?(?:([^@/\s]*)@)?([\w.-]+|\[[:\w]+\]):?(\d+)?([/?#].*)?$/i.exec(value);
105
+ if (!m || (!m[1] && !m[4])) return null;
106
+ const host = m[3].replace(/^\[|\]$/g, "");
107
+ const port = m[4] ? Number(m[4]) : m[1] === "https" ? 443 : PORT_OF[engine] ?? 80;
108
+ const [user, ...pass] = m[2] ? m[2].split(":") : [];
109
+ return {
110
+ engine, host, port, db: null,
111
+ conn: { base: `${m[1] === "https" ? "https" : "http"}://${host.includes(":") ? `[${host}]` : host}:${port}`, port, ...(user ? { user: dec(user), password: dec(pass.join(":")) } : {}) },
112
+ // A copy of the whole server speaks plain HTTP on this machine.
113
+ move: (to, toPort) => ({ [name]: `${m[1] ? "http://" : ""}${m[2] ? `${m[2]}@` : ""}${to}:${toPort}${m[5] ?? ""}` }),
114
+ };
115
+ }
116
+
117
+ // A search server's sign-in sits in settings beside its address: ELASTIC_PASSWORD, ES_API_KEY.
118
+ const BY_NAME_ELASTIC = BY_NAME.find(([e]) => e === "elasticsearch")[1];
119
+ function withLogin(store, values) {
120
+ if (store.conn.user) return store;
121
+ const own = (re) => values[Object.keys(values).find((n) => BY_NAME_ELASTIC.test(n) && re.test(n) && values[n])];
122
+ const apiKey = own(/API_?KEY$/);
123
+ const password = own(/(?:PASS|PASSWORD)$/);
124
+ if (apiKey) return { ...store, conn: { ...store.conn, apiKey } };
125
+ return password ? { ...store, conn: { ...store.conn, user: own(/USER(?:NAME)?$/) ?? "elastic", password } } : store;
100
126
  }
101
127
 
102
128
  // A vector store holds collections, and the app's settings name the ones it uses: QDRANT_COLLECTION,
@@ -116,6 +142,9 @@ function withCollections(store, values, others) {
116
142
  return named.map((n) => ({ ...store, conn, db: String(values[n]).trim(), names: [...store.names, n], point: (to) => ({ [n]: String(to) }) }));
117
143
  }
118
144
 
145
+ // MongoDB's tools take one address: the parts' own, written as one.
146
+ const mongoUri = (host, port, user, password) => `mongodb://${user ? `${encodeURIComponent(user)}:${encodeURIComponent(password ?? "")}@` : ""}${host || "127.0.0.1"}:${port}/`;
147
+
119
148
  // Settings that name one store in parts, grouped by what they share before the part:
120
149
  // POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB; DB_HOST, DB_NAME, DB_CONNECTION=pgsql; PGHOST, PGDATABASE.
121
150
  const HOST_PART = /^(.+?)_?(?:HOST(?:NAME)?|SERVER)$/;
@@ -148,10 +177,11 @@ function fromParts(values) {
148
177
  const db = parts.db ? (engine === "redis" ? Number(values[parts.db] || 0) : values[parts.db]) : engine === "redis" ? 0 : "";
149
178
  out.push({
150
179
  engine, host, port, db,
151
- conn: { host, port, user: values[parts.user] ?? "", password: values[parts.password] ?? "", sslmode: p === "PG" ? values.PGSSLMODE ?? "" : "" },
180
+ conn: { host, port, user: values[parts.user] ?? "", password: values[parts.password] ?? "", sslmode: p === "PG" ? values.PGSSLMODE ?? "" : "", ...(engine === "mongo" ? { uri: mongoUri(host, port, values[parts.user], values[parts.password]) } : {}) },
152
181
  names: [hostName ?? parts.user ?? parts.port, parts.db].filter(Boolean),
153
182
  // Only a setting that holds the database can point the app at another one.
154
183
  point: parts.db ? (to) => ({ [parts.db]: String(to) }) : null,
184
+ move: !hostName ? null : parts.port ? (to, toPort) => ({ [hostName]: to, [parts.port]: String(toPort) }) : inline ? (to, toPort) => ({ [hostName]: `${to}:${toPort}` }) : null,
155
185
  });
156
186
  }
157
187
  return out;
@@ -173,11 +203,12 @@ export function storesOf(values) {
173
203
  }
174
204
  found.push(...fromParts(values));
175
205
  const engines = found.map((s) => s.engine);
176
- const each = found.flatMap((s) => (s.engine === "qdrant" ? withCollections(s, values, engines) : [s]));
206
+ const each = found.flatMap((s) => (s.engine === "qdrant" ? withCollections(s, values, engines) : s.engine === "elasticsearch" ? [withLogin(s, values)] : [s]));
177
207
  const byKey = new Map();
178
208
  const merge = (held, s) => {
179
209
  held.names = [...new Set([...held.names, ...s.names])];
180
210
  if (s.point) held.points.push(s.point);
211
+ if (s.move) held.moves.push(s.move);
181
212
  held.conn ??= s.conn;
182
213
  };
183
214
  const whole = each.filter((s) => s.db !== "" && s.db !== null);
@@ -185,7 +216,7 @@ export function storesOf(values) {
185
216
  const key = `${at(s)}|${s.db ?? ""}`;
186
217
  const held = byKey.get(key) ?? (s.db === "" || s.db === null ? [...byKey.values()].find((h) => at(h) === at(s)) : undefined);
187
218
  if (held) { merge(held, s); continue; }
188
- byKey.set(key, { ...s, key, points: s.point ? [s.point] : [] });
219
+ byKey.set(key, { ...s, key, points: s.point ? [s.point] : [], moves: s.move ? [s.move] : [] });
189
220
  }
190
221
  return [...byKey.values()];
191
222
  }
@@ -194,37 +225,39 @@ export function storesOf(values) {
194
225
  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
226
  const unit = (s) => (s.engine === "qdrant" ? "collection" : "database");
196
227
  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]);
228
+ const copied = [...new Set([...Object.keys(COPY), ...Object.keys(SERVER)])].map((e) => ENGINE[e]);
198
229
  const copiedList = `${copied.slice(0, -1).join(", ")} and ${copied.at(-1)}`;
199
230
 
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"],
231
+ // Why no copy was made of a store on this machine, in a sentence of theirs, and the one thing to do.
232
+ // The store's own words are never quoted: they name users and hosts.
233
+ const HERE = {
234
+ started: () => ["your app was already running when this command started", "Stop your app"],
235
+ engine: (s) => [`we copy ${copiedList} for a run, not ${ENGINE[s.engine]}`, `Point ${s.names[0]} at a scratch ${s.db ? unit(s) : "server"}`],
236
+ fixed: (s) => [`no setting names its ${s.engine === "redis" ? "database number" : `${unit(s)} name`} to point the app at a copy`, `Put the ${s.engine === "redis" ? "database number" : `${unit(s)} name`} in a setting`],
237
+ auth: (s) => ["the password its settings carry was refused", `Check ${s.names[0]}`],
238
+ rights: () => ["the user it signs in as may not create databases", "Give that user the right to create databases"],
239
+ down: () => ["nothing answered at the address its settings give", "Start it"],
240
+ big: () => ["it is over the size a run copies", "Point it at a smaller scratch copy"],
241
+ missing: (s) => [`${TOOLS[s.engine] ?? "its own tools are"} not on this machine's PATH`, "Install them"],
242
+ version: () => ["this machine's copy of the store's own tools is older than the server", "Update them"],
243
+ oldServer: () => ["the server is older than Redis 6.2, which a copy needs", "Upgrade it"],
244
+ full: () => ["it has no empty database left to copy into", "Empty one of its databases"],
245
+ cluster: () => ["it runs as a cluster, which has one database only", "Point it at a scratch server"],
246
+ docker: () => ["a copy of it is made in Docker, which is not running on this machine", "Start Docker"],
247
+ login: (s) => ["your app signs in to it in a way a copy of it cannot be given", `Point ${s.names[0]} at a scratch server, or sign in with a user and password`],
248
+ container: () => ["Docker could not start a copy of it", "Check that Docker can pull and run its image"],
249
+ other: (s) => ["the copy did not go through", `Point ${s.names[0]} at a scratch ${s.db ? unit(s) : "server"}`],
213
250
  };
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}.`;
251
+ const heldHere = (s, why) => {
252
+ const [reason, fix] = (HERE[why] ?? HERE.other)(s);
253
+ return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at the ${whatOf(s)} on this machine, and ${reason}, so test conversations would save into it. ${fix}, then run the connect command again.`;
222
254
  };
223
255
  const kept = (s, clone, how) => (s.engine === "redis"
224
256
  ? `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
257
  : how === "empty"
226
258
  ? `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
259
  : `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.`);
260
+ const serverKept = (s) => `Your app runs against a copy of its whole ${ENGINE[s.engine]} server, made in a Docker container of ours, through ${listed(s.names)}; the original is not touched, and the container is deleted when this command ends and made again from the original at the next connect.`;
228
261
 
229
262
  // What holds Run for a store off this machine no copy could be made of, and the one thing to do.
230
263
  // The first reason is the most useful: a Neon store with no key says the key.
@@ -252,7 +285,7 @@ const heldWhy = (s, why, tool) => {
252
285
  // A Qdrant server the app writes into is kept by the hook in the app instead of by a copy made here:
253
286
  // the first write a run makes into a collection copies that collection on the same server and the
254
287
  // 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.
288
+ // holds nothing, and neither does a local one whose collection no setting names.
256
289
  export const GUARDED_ENGINES = new Set(["qdrant"]);
257
290
  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
291
  const hostedKept = (s, got) => (got.how === "neon"
@@ -271,13 +304,17 @@ export const bypassedOf = (connections, stores, blocked) => stores
271
304
  // A copy that throws is one that did not go through, said as such; it never stops the app starting.
272
305
  const drop = (s, clone, marker) => COPY[s.engine].drop(s, clone, marker).catch(() => false);
273
306
  const copy = (s, clone, marker) => COPY[s.engine].copy(s, clone, marker).catch(() => ({ why: "other" }));
307
+ const serve = (s, name) => SERVER[s.engine](s, name).catch(() => ({ why: "other" }));
308
+ const undo = (s, row) => (row.kind === "server" ? removeCopy(row.made) : drop(s, row.made, row.marker));
274
309
  const cloneName = (db) => `${String(db).slice(0, 40)}_cortad_${process.pid}`;
310
+ // Refusals a copy of the whole server would meet the same way.
311
+ const SERVER_TOO = new Set(["auth", "big", "down"]);
275
312
 
276
313
  // One session's copies. keep() copies the stores given that answer and says what it did; a store
277
314
  // nothing answers at yet is left out, unsaid, since trials cannot write into it: once it is started
278
315
  // (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.
316
+ // that died left behind are deleted by the next one, through the ledger. A store with no copy is
317
+ // `blocked`, and `pass` names the hosts of the copies made off this machine.
281
318
  // `values`, `dirs`, `fetchFn` and `local` are lib/hosted.mjs's.
282
319
  export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local }) {
283
320
  const book = ledger(ledgerFile);
@@ -290,9 +327,9 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
290
327
  const s = stores.find((x) => x.key === row.key);
291
328
  if (s && (await drop(s, row.made, row.marker))) book.remove(row);
292
329
  }
330
+ for (const row of book.orphans("server")) if (await removeCopy(row.made)) book.remove(row);
293
331
  await hosted.orphans();
294
332
  const env = {};
295
- const risks = [];
296
333
  const copies = [];
297
334
  const blocked = [];
298
335
  const pass = [];
@@ -315,30 +352,48 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
315
352
  }
316
353
  if (await down(s)) continue;
317
354
  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) {
355
+ if (!started) { blocked.push({ id: storeId(s), names: s.names, why: heldHere(s, "started") }); continue; }
356
+ let why = !COPY[s.engine] ? "engine" : !s.points.length || (s.engine !== "redis" && !s.db) ? "fixed" : null;
357
+ if (!why) {
358
+ const marker = `${process.pid}-${Date.now()}`;
359
+ // Recorded before the copy starts, so a session killed mid-copy leaves a row the next one
360
+ // undoes. A Redis copy's database is found by its marker then.
361
+ const row = { kind: "copy", key: s.key, made: s.engine === "redis" ? null : cloneName(s.db), marker };
362
+ book.add(row);
363
+ const got = await copy(s, cloneName(s.db), marker);
364
+ if (!got.why) {
365
+ const clone = got.index ?? cloneName(s.db);
366
+ made.push({ s, row });
367
+ for (const point of s.points) Object.assign(env, point(clone));
368
+ copies.push(kept(s, clone, got.how));
369
+ continue;
370
+ }
328
371
  if (await drop(s, row.made, marker)) book.remove(row);
329
372
  // 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;
373
+ if (got.why === "gone") continue;
374
+ why = got.why;
375
+ }
376
+ // The app names its databases in its code, or this machine lacks the store's own tools: the
377
+ // whole server is copied into a container of ours and the app's address moved to it.
378
+ if (SERVER[s.engine] && s.moves.length && !SERVER_TOO.has(why)) {
379
+ const row = { kind: "server", key: s.key, made: copyName(s.engine) };
380
+ book.add(row);
381
+ const got = await serve(s, row.made);
382
+ if (got.port) {
383
+ made.push({ s, row });
384
+ for (const move of s.moves) Object.assign(env, move("127.0.0.1", got.port));
385
+ copies.push(serverKept(s));
386
+ continue;
387
+ }
388
+ if (await removeCopy(row.made)) book.remove(row);
389
+ why = got.why;
332
390
  }
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));
391
+ blocked.push({ id: storeId(s), names: s.names, why: heldHere(s, why) });
337
392
  }
338
- return { env, risks, copies, blocked, pass };
393
+ return { env, copies, blocked, pass };
339
394
  },
340
395
  drop: async () => {
341
- for (const { s, row } of made.splice(0)) if (await drop(s, row.made, row.marker)) book.remove(row);
396
+ for (const { s, row } of made.splice(0)) if (await undo(s, row)) book.remove(row);
342
397
  await hosted.drop();
343
398
  },
344
399
  // The collections the hook in the app copied for a run's writes (lib/trace.cjs copyOf), deleted
package/lib/verbs.mjs CHANGED
@@ -6,6 +6,7 @@ import { askReach } from "./auto-reach.mjs";
6
6
  import { homeOf, projectOf } from "./home.mjs";
7
7
  import { reach as reachApp } from "./reach.mjs";
8
8
  import { fieldConnectText, fieldText, findingsText, finished, linesOf, notStartedText, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, upWaitingText, waitingText } from "./text.mjs";
9
+ import { incidentsText, intelligenceText, outcomesText, queryText } from "./intel-text.mjs";
9
10
 
10
11
  // The ten things a coding agent may ask of Cortad, each a call to the API with this machine's key
11
12
  // and a rendering from lib/text.mjs; `reach` also sends to the app on this machine. The MCP tools
@@ -286,11 +287,65 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
286
287
  return res.ok ? { text: fieldConnectText(res.data), data: res.data } : failed(res);
287
288
  };
288
289
 
290
+ // A world for an AI that is not a chat (the server's world/run.ts): built on this machine from the
291
+ // app's own seed, planted with problems whose answers are known, discovered by the app's own code.
292
+ // `ask`: what the agent built or wants tested, kept with the report. Answers at once with the id.
293
+ const world = async ({ ask } = {}) => {
294
+ const res = await call("POST", "/mcp/world", ask ? { ask: String(ask) } : {});
295
+ if (!res.ok) return failed(res);
296
+ if (res.data?.joined) return { text: `A world is already being tested: ${res.data.jobId}.\nnext: world_status ${res.data.jobId}`, data: res.data };
297
+ if (!res.data?.started) return { text: `${res.data?.why ?? "No world started."}\nNothing ran.`, data: res.data, isError: true, code: "world_refused" };
298
+ const planted = Array.isArray(res.data.situations) && res.data.situations.length ? `Planted: ${res.data.situations.join("; ")}.` : `${res.data.worlds ?? ""} worlds.`;
299
+ return { text: `World started: ${res.data.jobId}. ${planted} Each is tested ${res.data.repeats} times. The person sees it at ${res.data.url}\nnext: world_status ${res.data.jobId}`, data: res.data };
300
+ };
301
+ // The one door: the agent says what it built or wants tested, and Cortad decides what kind of test
302
+ // that is. A world where the app has one and the ask names something it can plant; a run of the
303
+ // app's chat surfaces otherwise. The answer says which, and what to follow it with.
304
+ const test = async ({ ask } = {}) => {
305
+ const res = await call("POST", "/mcp/world", { ...(ask ? { ask: String(ask) } : {}), only: "matched" });
306
+ if (res.ok && res.data?.started) return { text: `This is tested in a world. World started: ${res.data.jobId}. Planted: ${(res.data.situations ?? []).join("; ")}. Each is tested ${res.data.repeats} times. The person sees it at ${res.data.url}\nnext: world_status ${res.data.jobId}`, data: res.data };
307
+ if (res.ok && res.data?.joined) return { text: `A world is already being tested: ${res.data.jobId}.\nnext: world_status ${res.data.jobId}`, data: res.data };
308
+ const why = res.ok ? (res.data?.unmatched ? "Nothing a world can plant is what that is about, so it is tested by a run of the app's chat surfaces." : "No world is written for this app yet, so it is tested by a run of the app's chat surfaces.") : "The world could not be asked, so it is tested by a run of the app's chat surfaces.";
309
+ const ran = await run({});
310
+ return { ...ran, text: `${why}\n${ran.text}` };
311
+ };
312
+ const worldStatus = async ({ jobId } = {}) => {
313
+ if (!jobId) return { text: "Name the world: world_status <id>.", isError: true, code: "no_id" };
314
+ const res = await call("GET", `/mcp/world/${encodeURIComponent(String(jobId))}`);
315
+ if (!res.ok) return failed(res);
316
+ const running = res.data.status === "queued" || res.data.status === "running";
317
+ return { text: `${res.data.text}\nThe person sees this at ${res.data.url}\nnext: ${running ? `world_status ${jobId}` : "status"}`, data: res.data };
318
+ };
319
+
289
320
  const field = async ({ days } = {}) => {
290
321
  const res = await call("GET", `/mcp/field${days ? `?days=${Number(days)}` : ""}`);
291
322
  return res.ok ? { text: fieldText(res.data), data: res.data } : failed(res);
292
323
  };
293
324
 
325
+ // The Intelligence Layer: the board in numbers, what is wrong, what counts as an outcome, a
326
+ // subscription to incidents, and a question to the warehouse.
327
+ const intelligence = async ({ days } = {}) => {
328
+ const res = await call("GET", `/mcp/intelligence${days ? `?days=${Number(days)}` : ""}`);
329
+ return res.ok ? { text: intelligenceText(res.data), data: res.data } : failed(res);
330
+ };
331
+ const incidents = async () => {
332
+ const res = await call("GET", "/mcp/incidents");
333
+ return res.ok ? { text: incidentsText(res.data), data: res.data } : failed(res);
334
+ };
335
+ const outcomes = async ({ proposals } = {}) => {
336
+ const res = proposals ? await call("POST", "/mcp/outcomes", { proposals }) : await call("GET", "/mcp/outcomes");
337
+ return res.ok ? { text: outcomesText(res.data), data: res.data } : failed(res);
338
+ };
339
+ const subscribe = async ({ url }) => {
340
+ const res = await call("POST", "/mcp/subscribe", { url });
341
+ return res.ok ? { text: `Subscribed. ${res.data.said}
342
+ Secret, shown once: ${res.data.secret}`, data: res.data } : failed(res);
343
+ };
344
+ const query = async (ask) => {
345
+ const res = await call("POST", "/mcp/query", ask);
346
+ return res.ok ? { text: queryText(res.data), data: res.data } : { text: queryText(res.data ?? { error: res.text }), data: res.data };
347
+ };
348
+
294
349
  // `status --changed`: the files Cortad cites that changed since the latest run started, with the
295
350
  // findings at them. Empty when there is nothing to say, or nothing could be asked. The code review's
296
351
  // files are those of the review shown: the read's misses, or the lines Cortad's team's review cites.
@@ -312,7 +367,7 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
312
367
  };
313
368
 
314
369
  // `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
315
- return { status, reach, run, run_status: runStatus, wait, findings, verify, dispute, feedback, field_connect: fieldConnect, field, post, changed };
370
+ return { status, reach, run, run_status: runStatus, wait, findings, verify, dispute, feedback, field_connect: fieldConnect, field, world, world_status: worldStatus, test, connect: fieldConnect, intelligence, incidents, outcomes, subscribe, query, post, changed };
316
371
  }
317
372
 
318
373
  const sizeOf = (path) => { try { return statSync(path).size; } catch { return 0; } };