cortad 0.3.1-rc.11 → 0.3.1-rc.13

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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/backing.mjs +230 -0
  4. package/lib/cli.mjs +31 -18
  5. package/lib/contact-text.mjs +119 -0
  6. package/lib/data.mjs +10 -16
  7. package/lib/dissection-text.mjs +227 -0
  8. package/lib/env-secrets.mjs +23 -0
  9. package/lib/failed-text.mjs +3 -3
  10. package/lib/fresh.mjs +33 -12
  11. package/lib/health.mjs +53 -0
  12. package/lib/held.mjs +67 -0
  13. package/lib/home.mjs +4 -17
  14. package/lib/hosted.mjs +201 -0
  15. package/lib/identities.mjs +1 -1
  16. package/lib/keeping.mjs +162 -0
  17. package/lib/ledger.mjs +20 -0
  18. package/lib/lock.mjs +6 -1
  19. package/lib/mcp.mjs +11 -10
  20. package/lib/mint.mjs +171 -36
  21. package/lib/mongo.mjs +74 -0
  22. package/lib/numbers-text.mjs +65 -0
  23. package/lib/pg.mjs +118 -0
  24. package/lib/proc.mjs +100 -0
  25. package/lib/proof.mjs +782 -0
  26. package/lib/proxy.mjs +183 -0
  27. package/lib/pyhook/cortad_sites.py +218 -0
  28. package/lib/pyhook/cortad_tied.py +284 -0
  29. package/lib/pyhook/sitecustomize.py +952 -124
  30. package/lib/qdrant.mjs +47 -0
  31. package/lib/read-text.mjs +3 -3
  32. package/lib/redis.mjs +130 -0
  33. package/lib/replay.mjs +386 -33
  34. package/lib/runner.mjs +85 -0
  35. package/lib/service.mjs +25 -0
  36. package/lib/start.mjs +33 -2
  37. package/lib/stick.mjs +1 -1
  38. package/lib/stores.mjs +341 -0
  39. package/lib/switches.mjs +36 -0
  40. package/lib/tether.mjs +9 -0
  41. package/lib/text.mjs +223 -156
  42. package/lib/tied.cjs +217 -0
  43. package/lib/trace.cjs +344 -558
  44. package/lib/verbs.mjs +47 -17
  45. package/lib/wire.cjs +818 -0
  46. package/lib/words.mjs +2 -0
  47. package/local.mjs +444 -271
  48. package/package.json +26 -1
  49. package/skill/SKILL.md +49 -42
  50. package/skill/references/results.md +221 -82
package/README.md CHANGED
@@ -6,7 +6,7 @@ Runs Cortad's test conversations against the AI app on your machine, and gives y
6
6
  npx cortad <code>
7
7
  ```
8
8
 
9
- Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex, Cursor and Copilot on this machine. The first run starts on its own. Ctrl-C disconnects.
9
+ Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex, Cursor and Copilot on this machine. A run starts when you press Run in the browser or ask your agent for one. Ctrl-C disconnects.
10
10
 
11
11
  ## Your coding agent
12
12
 
@@ -0,0 +1,9 @@
1
+ // Which listening port is the app. A database that answers an HTTP probe on its own port is not the
2
+ // app: MongoDB says so in words, and AI Answers' run followed its in-memory MongoDB after a restart
3
+ // and sent every trial there. A database's own address in the log (mongodb://127.0.0.1:52011) names
4
+ // the store, never the app.
5
+ const NOT_AN_APP = /trying to access MongoDB over HTTP|^-ERR |^\$\d+\r?\n|^\+PONG/m;
6
+ const STORE_URL = /\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|redis|rediss|mysql|mariadb|amqp|nats):\/\/\S+/gi;
7
+
8
+ export const notAnApp = (head) => NOT_AN_APP.test(head);
9
+ export const withoutStores = (log) => log.replace(STORE_URL, "");
@@ -0,0 +1,230 @@
1
+ // The services your app reaches on this machine, brought up when nothing answers there and the
2
+ // repository says how: a store its compose file runs from an image, with Docker, or a second
3
+ // service of its own, with that service's own start. Only a port the app is known to reach is
4
+ // brought up: one its settings name when it could not start without it, or one the hook saw it
5
+ // reach for. A compose file often lists a store the app is set up not to use, and starting it
6
+ // would be a download of gigabytes nobody asked for. Each is stopped when this command ends, and
7
+ // one that could not be brought up, or that nothing here brings up, is said before a run, with whose
8
+ // side it is on.
9
+ import { execFile } from "node:child_process";
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { connect } from "node:net";
12
+ import { basename, dirname, join, relative } from "node:path";
13
+ import { ledger } from "./ledger.mjs";
14
+ import { servicePort } from "./service.mjs";
15
+ import { startPlan, workspaces } from "./start.mjs";
16
+
17
+ // Something accepts a connection on the port: a database answers no HTTP, so this is the test.
18
+ export function tcpOpen(host, port, ms = 1500) {
19
+ return new Promise((done) => {
20
+ const s = connect({ host: host && !host.startsWith("/") && host !== "0.0.0.0" ? host.replace(/^\[|\]$/g, "") : "127.0.0.1", port });
21
+ const end = (ok) => { s.destroy(); done(ok); };
22
+ s.setTimeout(ms, () => end(false));
23
+ s.once("connect", () => end(true));
24
+ s.once("error", () => end(false));
25
+ });
26
+ }
27
+ async function waitTcp(port, ms, gone = () => false) {
28
+ for (const until = Date.now() + ms; Date.now() < until && !gone(); await new Promise((r) => setTimeout(r, 1000))) if (await tcpOpen("127.0.0.1", port)) return true;
29
+ return false;
30
+ }
31
+
32
+ const COMPOSE_FILE = /^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/;
33
+ // The files compose reads by default come first: a second file beside them is a variant.
34
+ const DEFAULT_FIRST = (a, b) => Number(/^(?:docker-)?compose\.ya?ml$/.test(basename(b))) - Number(/^(?:docker-)?compose\.ya?ml$/.test(basename(a))) || a.localeCompare(b);
35
+ export function composeFiles(dirs) {
36
+ const out = new Set();
37
+ for (const dir of dirs) { try { for (const f of readdirSync(dir)) if (COMPOSE_FILE.test(f)) out.add(join(dir, f)); } catch { /* no folder */ } }
38
+ return [...out].sort(DEFAULT_FIRST);
39
+ }
40
+
41
+ const unquote = (v) => String(v ?? "").trim().replace(/^(['"])(.*)\1$/, "$2");
42
+ // ${PG_PORT:-5432} and ${PG_PORT}, from the settings the app starts with.
43
+ const interpolate = (v, values) => v.replace(/\$\{(\w+)(?::?-([^}]*))?\}/g, (_, name, dflt) => values[name] || dflt || "");
44
+ // The host port of one port mapping: "5432:5432", "127.0.0.1:5432:5432/tcp". None for a container
45
+ // port alone (compose picks the host's) or a range.
46
+ function published(entry, values) {
47
+ const parts = interpolate(unquote(entry), values).replace(/\/(?:tcp|udp)$/, "").split(":");
48
+ const host = parts.length >= 2 ? parts[parts.length - 2] : "";
49
+ return /^\d+$/.test(host) ? Number(host) : null;
50
+ }
51
+
52
+ // The services a compose file declares: name, whether it runs an image or builds this repository's
53
+ // code, and the host ports it publishes. A reader of the shape compose files are written in, not of
54
+ // YAML at large.
55
+ // ponytail: no anchors, extends, include or profiles; add them when a repository needs one to be read.
56
+ export function composeServices(text, values = {}) {
57
+ const out = [];
58
+ let inServices = false, nameIndent = -1, keyIndent = -1, svc = null, portsIndent = -1;
59
+ for (const raw of String(text).split("\n")) {
60
+ const line = raw.replace(/\t/g, " ").replace(/\s+#.*$/, "");
61
+ const t = line.trim();
62
+ if (!t || t.startsWith("#")) continue;
63
+ const indent = line.length - line.trimStart().length;
64
+ if (indent === 0) { inServices = /^services:\s*$/.test(t); svc = null; continue; }
65
+ if (!inServices) continue;
66
+ if (nameIndent < 0) nameIndent = indent;
67
+ if (indent <= nameIndent) {
68
+ const m = /^["']?([\w.-]+)["']?:\s*$/.exec(t);
69
+ svc = indent === nameIndent && m ? { name: m[1], image: null, build: false, ports: [] } : null;
70
+ if (svc) out.push(svc);
71
+ keyIndent = -1; portsIndent = -1;
72
+ continue;
73
+ }
74
+ if (!svc) continue;
75
+ if (portsIndent >= 0 && indent > portsIndent) {
76
+ const item = /^-\s*(.*)$/.exec(t);
77
+ const port = item && !/^[A-Za-z_]\w*:(?:\s|$)/.test(item[1]) ? published(item[1], values) : Number(unquote(/^(?:-\s*)?published:\s*(.+)$/.exec(t)?.[1] ?? "")) || null;
78
+ if (port) svc.ports.push(port);
79
+ continue;
80
+ }
81
+ portsIndent = -1;
82
+ if (keyIndent < 0) keyIndent = indent;
83
+ if (indent !== keyIndent) continue;
84
+ const kv = /^([\w-]+):\s*(.*)$/.exec(t);
85
+ if (!kv) continue;
86
+ if (kv[1] === "image") svc.image = unquote(kv[2]);
87
+ else if (kv[1] === "build") svc.build = true;
88
+ else if (kv[1] === "ports") {
89
+ if (kv[2].startsWith("[")) for (const e of kv[2].replace(/^\[|\]$/g, "").split(",")) { const p = published(e, values); if (p) svc.ports.push(p); }
90
+ else portsIndent = indent;
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // A setting that is an address a page is opened from, not a service the app calls.
97
+ // The app's own address and sign-in redirects are pages, not services: ai-appointment-setter's
98
+ // NEXT_PUBLIC_APP_URL serves only its OAuth redirect, and a run was held on it.
99
+ const PAGE = /(?:^|_)(?:ORIGINS?|FRONTEND|CLIENT|SITE|WEB|CALLBACK|REDIRECT|CORS|ALLOWED|HOMEPAGE|APP|BASE|NEXTAUTH)(?:_|$)/;
100
+ // Addresses on this machine the app's settings point at: http://localhost:4000/api under API_URL.
101
+ export function localAddresses(values) {
102
+ const byPort = new Map();
103
+ for (const [name, raw] of Object.entries(values)) {
104
+ if (PAGE.test(name)) continue;
105
+ let u;
106
+ try { u = new URL(String(raw ?? "").trim()); } catch { continue; }
107
+ if (!/^https?:$/.test(u.protocol) || !u.port || !/^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i.test(u.hostname)) continue;
108
+ const port = Number(u.port);
109
+ byPort.set(port, [...(byPort.get(port) ?? []), name]);
110
+ }
111
+ return [...byPort].map(([port, names]) => ({ port, names }));
112
+ }
113
+
114
+ const run = (bin, args, opts = {}) => new Promise((done) => {
115
+ execFile(bin, args, { timeout: 180_000, maxBuffer: 4 * 1024 * 1024, ...opts }, (err, stdout, stderr) => done({ ok: !err, out: String(stdout ?? ""), err: String(stderr ?? err?.message ?? "") }));
116
+ });
117
+ // A service's own last words go into a sentence that leaves this machine: an address's password never does.
118
+ const lastLine = (text) => String(text).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/:\/\/[^\s/@:]+:[^\s/@]+@/g, "://[masked]@").split("\n").map((l) => l.trim()).filter(Boolean).at(-1)?.slice(0, 160) ?? "";
119
+
120
+ // How compose is run here: the docker plugin, the older standalone binary, or not at all and why.
121
+ async function composeCommand(onPath) {
122
+ if (!onPath("docker") && !onPath("docker-compose")) return { why: "Docker is not installed on this machine" };
123
+ if (!(await run("docker", ["info"], { timeout: 15_000 })).ok) return { why: "Docker is not running on this machine" };
124
+ if ((await run("docker", ["compose", "version"], { timeout: 15_000 })).ok) return { bin: "docker", pre: ["compose"] };
125
+ return onPath("docker-compose") ? { bin: "docker-compose", pre: [] } : { why: "Docker here has no compose command" };
126
+ }
127
+
128
+ const upper = (s) => s.charAt(0).toUpperCase() + s.slice(1);
129
+ // Why a port is wanted: a setting names it, or the app was seen reaching for it.
130
+ const pointer = (w) => (!w.names?.length ? "Your app reached for"
131
+ : w.names.length === 1 ? `${w.names[0]} points at` : `${w.names[0]} and ${w.names.length - 1} more setting${w.names.length > 2 ? "s" : ""} point at`);
132
+ const said = {
133
+ up: (what, how, w) => `Started ${what} ${how}: ${pointer(w)} port ${w.port} on this machine and nothing answered there. It is stopped when this command ends.`,
134
+ theirs: (what, why, w) => `${pointer(w)} port ${w.port} on this machine and nothing answers there. ${upper(what)} would start it, but ${why}: start it yourself before a run. This is on your side.`,
135
+ exited: (what, tail, w) => `${pointer(w)} port ${w.port} on this machine, which ${what} serves, and it stopped before it answered: ${tail}. This is on your side.`,
136
+ none: (w) => `${pointer(w)} port ${w.port} on this machine and nothing answers there, and nothing in this repository starts it: start it yourself before a run. This is on your side.`,
137
+ slow: (what, secs, w) => `${pointer(w)} port ${w.port} on this machine; ${what} was started for it, but nothing answered there within ${secs} seconds. This is on our side.`,
138
+ };
139
+
140
+ // One session's services. up(wants) brings up each wanted port it can, where nothing answers: a
141
+ // compose store first, else a workspace of this repository that serves it. `launch` starts a
142
+ // workspace the way the app is started and hands back its process. Answers what it said and the
143
+ // ports that now answer.
144
+ export function makeBacking({ root, appDir, values, onPath, ledgerFile, launch, adopt = true, storeWaitMs = 120_000, serviceWaitMs = 90_000 }) {
145
+ const book = ledger(ledgerFile);
146
+ const started = [];
147
+ const tried = new Set();
148
+ const lastSaid = new Map();
149
+ let compose = null;
150
+ const files = composeFiles([...new Set([appDir, root])]).map((file) => ({ file, services: (() => { try { return composeServices(readFileSync(file, "utf8"), values); } catch { return []; } })() }));
151
+ // Services a session that died left running are stopped by the next session that starts the app,
152
+ // never removed: the person may have used them since, and a removed container takes what they put
153
+ // in it. A session attached to an app already running leaves them to that app.
154
+ for (const row of adopt ? book.orphans("service") : []) {
155
+ const [file, service] = row.key.split("#");
156
+ if (!existsSync(file)) continue;
157
+ const kept = { ...row, made: "stop" };
158
+ book.remove(row);
159
+ book.add(kept);
160
+ started.push({ file, service, row: kept });
161
+ }
162
+
163
+ async function fromCompose(found, w) {
164
+ const where = `the ${found.service} service in ${relative(root, found.file) || basename(found.file)}`;
165
+ compose ??= await composeCommand(onPath);
166
+ if (compose.why) return said.theirs(where, compose.why, w);
167
+ const opts = { cwd: dirname(found.file), env: { ...process.env } };
168
+ const args = (...a) => [...compose.pre, "-f", found.file, ...a];
169
+ const existed = Boolean((await run(compose.bin, args("ps", "-a", "-q", found.service), opts)).out.trim());
170
+ const up = await run(compose.bin, args("up", "-d", found.service), opts);
171
+ if (!up.ok) return said.theirs(where, `Docker could not start it: ${lastLine(up.err)}`, w);
172
+ const row = { kind: "service", key: `${found.file}#${found.service}`, made: existed ? "stop" : "remove" };
173
+ book.add(row);
174
+ started.push({ file: found.file, service: found.service, row });
175
+ if (!(await waitTcp(w.port, storeWaitMs))) return said.slow(where, Math.round(storeWaitMs / 1000), w);
176
+ return said.up(where, "with Docker", w);
177
+ }
178
+
179
+ async function fromWorkspace(dir, w) {
180
+ const plan = startPlan({ root: dir, onPath });
181
+ if (!plan?.cmd) return null;
182
+ const what = relative(root, dir) || basename(dir);
183
+ const kid = launch(plan);
184
+ let exited = null;
185
+ let tail = "";
186
+ kid.on("exit", (code) => { exited = code ?? 1; });
187
+ const keep = (d) => { tail = (tail + d).slice(-2000); };
188
+ kid.stdout?.on("data", keep);
189
+ kid.stderr?.on("data", keep);
190
+ if (await waitTcp(w.port, serviceWaitMs, () => exited !== null)) return said.up(what, `with ${plan.cmd}`, w);
191
+ if (exited !== null) return said.exited(what, lastLine(tail) || `it exited ${exited}`, w);
192
+ return said.slow(what, Math.round(serviceWaitMs / 1000), w);
193
+ }
194
+
195
+ return {
196
+ // wants: [{ port, names, store }], each a port on this machine the app reaches or its settings
197
+ // name; `store` lets a compose image serve it. Answers what was said and the ports brought up.
198
+ // `down`: the ports a setting names that still answer nothing after this, each with what was
199
+ // said of it: a run without them is a run against an app missing a part, and holds.
200
+ async up(wants) {
201
+ const lines = [];
202
+ const ports = [];
203
+ const down = [];
204
+ const still = (w, why) => { if (w.names?.length && why) down.push({ port: w.port, names: w.names, why }); };
205
+ for (const w of wants) {
206
+ if (await tcpOpen("127.0.0.1", w.port)) continue;
207
+ if (tried.has(w.port)) { still(w, lastSaid.get(w.port)); continue; }
208
+ const image = w.store ? files.flatMap(({ file, services }) => services.filter((x) => x.image && !x.build && x.ports.includes(w.port)).map((x) => ({ file, service: x.name })))[0] : null;
209
+ const dir = image ? null : workspaces(root).find((d) => d !== appDir && servicePort(d) === w.port);
210
+ // A port only seen reached, and not a store's, may be a passing connection of the app's own.
211
+ if (!image && !dir && !w.names?.length && !w.store) continue;
212
+ tried.add(w.port);
213
+ const line = !image && !dir ? said.none(w) : image ? await fromCompose(image, w) : await fromWorkspace(dir, w);
214
+ if (line) { lines.push(line); lastSaid.set(w.port, line); }
215
+ if (await tcpOpen("127.0.0.1", w.port)) ports.push(w.port);
216
+ else still(w, line);
217
+ }
218
+ return { lines, ports, down };
219
+ },
220
+ // Compose services only: a workspace's process is the caller's, stopped with the app.
221
+ async stop() {
222
+ compose ??= started.length ? await composeCommand(onPath) : null;
223
+ if (!compose || compose.why) return;
224
+ for (const { file, service, row } of started.splice(0)) {
225
+ const how = row.made === "stop" ? ["stop", service] : ["rm", "-s", "-f", service];
226
+ if ((await run(compose.bin, [...compose.pre, "-f", file, ...how], { cwd: dirname(file), env: { ...process.env } })).ok) book.remove(row);
227
+ }
228
+ },
229
+ };
230
+ }
package/lib/cli.mjs CHANGED
@@ -3,10 +3,12 @@ import { spawn } from "node:child_process";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { makeCode } from "./fresh.mjs";
6
- import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
6
+ import { homeOf, projectOf, readPending, readToken, writePending } from "./home.mjs";
7
+ import { readRunner } from "./runner.mjs";
7
8
  import { markRun, putBack, writesDirOf } from "./writes.mjs";
8
9
  import { serveMcp } from "./mcp.mjs";
9
10
  import { SHOW } from "./read-text.mjs";
11
+ import { answeringText, notStartedText } from "./text.mjs";
10
12
  import { cliSpec, VERSION } from "./spec.mjs";
11
13
  import { hookOutput, stick, unstick } from "./stick.mjs";
12
14
  import { makeVerbs, READY_MS } from "./verbs.mjs";
@@ -46,6 +48,7 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
46
48
  root,
47
49
  writes,
48
50
  token: () => readToken(project),
51
+ runner: () => readRunner(project),
49
52
  pending,
50
53
  startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
51
54
  code: makeCode({ project, root, waitMs: mcp ? RESTART_MS.mcp : RESTART_MS.shell }),
@@ -72,7 +75,8 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
72
75
  }
73
76
 
74
77
  // Positional arguments for the shell face: `status rules 2`, `verify f1`, `run_status <jobId>`,
75
- // `findings 2`, `dispute f1 "why"`. A bare number is a page; in findings anything else is a run id.
78
+ // `findings 2`, `findings numbers 3`, `dispute f1 "why"`. A bare number is a page; in findings the
79
+ // word numbers is the whole table and anything else is a run id.
76
80
  function argsOf(command, rest) {
77
81
  const page = rest.find((w) => /^\d+$/.test(w));
78
82
  switch (command.replace(/-/g, "_")) {
@@ -82,8 +86,9 @@ function argsOf(command, rest) {
82
86
  }
83
87
  case "run_status": return rest[0] ? { jobId: rest[0] } : {};
84
88
  case "findings": {
85
- const jobId = rest.find((w) => w !== "--page" && !/^\d+$/.test(w));
86
- return { ...(jobId ? { jobId } : {}), ...(page ? { page: Number(page) } : {}) };
89
+ const show = rest.includes("numbers") ? { show: "numbers" } : {};
90
+ const jobId = rest.find((w) => w !== "--page" && w !== "numbers" && !/^\d+$/.test(w));
91
+ return { ...show, ...(jobId ? { jobId } : {}), ...(page ? { page: Number(page) } : {}) };
87
92
  }
88
93
  case "verify": return { findingId: rest[0], ...(rest[1] ? { jobId: rest[1] } : {}) };
89
94
  case "dispute": return { findingId: rest[0], why: rest.slice(1).join(" ") };
@@ -104,37 +109,45 @@ function requestOf(raw) {
104
109
 
105
110
  // Returns at once. The runner holds the app up: from the MCP it lives as long as the MCP does;
106
111
  // from a shell command it stays until the runs stop for a while (--until-idle). The waiter is
107
- // detached either way and ends once the run is posted or the app did not come up.
112
+ // detached either way and ends once the run is posted or the app did not come up. The runner takes
113
+ // the project's lock itself (lib/runner.mjs): one started beside a live one leaves at once.
108
114
  export function startApp({ root, project, env, keep, children, args, kind, spawnImpl = spawn }) {
109
115
  const log = openSync(join(homeOf(project), "runner.log"), "a");
110
116
  if (!readRunner(project)) {
111
117
  const child = spawnImpl(process.execPath, [LOCAL, "--token", ...(keep ? [] : ["--until-idle"])], { cwd: root, env, stdio: ["ignore", log, log], detached: !keep });
112
118
  if (keep) children.push(child); else child.unref();
113
- writeRunner(project, { pid: child.pid, startedAt: new Date().toISOString(), by: keep ? "mcp" : "cli" });
114
119
  }
115
120
  spawnImpl(process.execPath, [LOCAL, WAITER, JSON.stringify({ kind, args })], { cwd: root, env, stdio: ["ignore", log, log], detached: true }).unref();
116
121
  closeSync(log);
117
122
  return { ok: true };
118
123
  }
119
124
 
120
- // The waiter: posts the run once the app answers and leaves the outcome in pending.json.
121
- export async function runWhenUp({ verbs, project, env, pending, request, poll = appOf, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), readyMs = READY_MS }) {
125
+ // The waiter: posts the run once the runner says the app is up and Cortad can take it, and leaves
126
+ // the outcome in pending.json. The app's state is the runner's; `poll` asks the server whether it can
127
+ // play the app, and its latest reason why not is what a timeout says.
128
+ export async function runWhenUp({ verbs, project, env, pending, request, poll = appOf, runner = () => readRunner(project), now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), readyMs = READY_MS }) {
122
129
  const log = join(homeOf(project), "runner.log");
123
130
  const fail = (text) => pending.write({ ...pending.read(), error: text });
124
131
  const until = now() + readyMs;
125
- let said = "";
132
+ const minutes = Math.round(readyMs / 60_000);
133
+ let here = null;
134
+ let app = null;
126
135
  while (now() < until) {
127
136
  await sleep(3000);
128
- const app = await poll(env, project);
129
- if (app?.state === "ready-to-test") {
130
- const out = await verbs.post(request.args, request.kind);
131
- if (!out.data?.jobId) fail(out.text);
132
- return;
133
- }
134
- if (app?.said) said = ` ${app.said}`;
135
- if (!readRunner(project)) return fail(`The process holding your app up ended before the app answered.${said} Its output is in ${log}.\nNothing ran.`);
137
+ here = runner();
138
+ if (!here) return fail(`The process holding your app up ended before the app answered. Its output is in ${log}.\nNothing ran.`);
139
+ if (here.state === "failed") return fail(notStartedText(here));
140
+ if (here.state !== "up") continue;
141
+ const seen = await poll(env, project);
142
+ app = seen ?? app;
143
+ if (seen?.state !== "ready-to-test") continue;
144
+ const out = await verbs.post(request.args, request.kind);
145
+ if (!out.data?.jobId) fail(out.text);
146
+ return;
136
147
  }
137
- fail(`Your app did not answer within ${Math.round(readyMs / 60_000)} minutes.${said} Its output is in ${log}.\nNothing ran.`);
148
+ fail(here?.state === "up"
149
+ ? `${answeringText(here, app)}\nThe ${request.kind} did not start within ${minutes} minutes.\nNothing ran.`
150
+ : `Your app did not answer within ${minutes} minutes. Its output is in ${log}.\nNothing ran.`);
138
151
  }
139
152
 
140
153
  async function appOf(env, project) {
@@ -0,0 +1,119 @@
1
+ import { at, clip, has, num, plural, SIDE } from "./words.mjs";
2
+
3
+ // What `status` says between connect and a run: each endpoint a real request proved, with what the
4
+ // hook saw inside those requests; each endpoint the read found that no request has reached; what a
5
+ // run on the proven ones would be, and what it would write into. Every figure is one the agent can
6
+ // check against its own request or a line of the code. A field the API leaves out prints nothing.
7
+
8
+ const CARRIER = {
9
+ body: (k) => `the body field "${k}"`,
10
+ cookie: (k) => `the cookie "${k}"`,
11
+ header: (k) => `the header "${k}"`,
12
+ path: (k) => `the path segment {${k}}`,
13
+ history: (k) => `the earlier messages the client sends in "${k}"`,
14
+ };
15
+ const where = (x) => (x?.file || x?.path ? `${at(x.file ?? x.path, x.line)}: ` : "");
16
+ const TIED = { sent: "the first request sent", got: "the first request got back" };
17
+
18
+ // A door whose answer came on a second request: that request, what tied the two, and what the client
19
+ // sent first in each turn, each as the agent's own requests showed it.
20
+ function secondStepLine(s) {
21
+ if (!s?.request) return null;
22
+ const tie = s.tie?.key && TIED[s.tie.from] ? `, tied by the "${s.tie.key}" ${TIED[s.tie.from]}` : "";
23
+ const before = s.before?.length ? `; each turn first sent ${s.before.join(", ")}` : "";
24
+ return `The answer came on a second request, ${s.request}${tie}${before}.`;
25
+ }
26
+
27
+ function sessionLine(v) {
28
+ const s = v.session;
29
+ if (!s || !(v.requests > 1)) return null;
30
+ if (!s.held) return "No later request's prompt carried an earlier one.";
31
+ const by = CARRIER[s.carrier] && s.key ? CARRIER[s.carrier](s.key) : "nothing the request sent, so every caller shares what the app keeps";
32
+ return `A later request's prompt carried an earlier one, tied by ${by}.`;
33
+ }
34
+
35
+ function rulesLine(r) {
36
+ if (!r || !has(r.of) || r.of === 0) return null;
37
+ const head = r.carried ? `The prompts carried ${num(r.carried)} of the ${plural(r.of, "rule")} read from your code` : `The prompts carried none of the ${plural(r.of, "rule")} read from your code`;
38
+ const example = (r.examples ?? [])[0];
39
+ return `${head}${example ? `, for example ${at(example.path, example.line)} "${clip(example.text, 160)}"` : ""}.`;
40
+ }
41
+
42
+ const sideOf = (side) => (SIDE[side] ? `, ${SIDE[side]}` : "");
43
+ // A door runs do not use yet, or one only Cortad's requests reached, and why.
44
+ function standingLine(v) {
45
+ if (v.standing === "excluded") return `Runs will not use this endpoint yet${sideOf(v.side)}: ${v.why}`;
46
+ if (v.standing === "fallback") return `Proven by Cortad's requests alone: ${v.why}`;
47
+ return null;
48
+ }
49
+
50
+ // Who the conversations through a signed-in endpoint act as: the customers the agent's requests signed in as.
51
+ const accountsLine = (n) => (!has(n) ? null : n > 1 ? `Conversations are split across ${num(n)} accounts, one for each customer your requests signed in as.` : "All conversations act as one account.");
52
+
53
+ // Where a trial takes each value the app's own client sets for its model.
54
+ const appFieldLine = (f) => (f.from === "app" ? `"${f.field}" is set by your app${f.path ? ` at ${f.path}` : ""}.` : `"${f.field}" is only in your agent's request.`);
55
+
56
+ // The last exchange through the door; an ask the proof could not find in the request is left out.
57
+ function sampleLine(s) {
58
+ if (!s?.reply) return null;
59
+ const cut = s.cut ? ", and the reply stopped before it finished" : "";
60
+ return s.ask ? `Last request: "${clip(s.ask, 160)}", answered "${clip(s.reply, 240)}"${cut}.` : `Last reply: "${clip(s.reply, 240)}"${cut}.`;
61
+ }
62
+
63
+ function doorLines(v) {
64
+ const reached = `${plural(v.requests, "request")} reached ${v.model ?? "the model"}`;
65
+ const calls = has(v.modelCallsPerRequest) ? `, ${plural(v.modelCallsPerRequest, "model call")} each` : "";
66
+ const seconds = has(v.replySeconds) ? `, ${v.replySeconds} seconds a reply` : "";
67
+ const lines = [` ${v.door}: ${reached}${calls}${seconds}.`];
68
+ const inner = [
69
+ standingLine(v),
70
+ v.askField ? `The message goes in "${v.askField}".` : null,
71
+ ...(v.appFields ?? []).map(appFieldLine),
72
+ secondStepLine(v.secondStep),
73
+ v.later ? "Your app takes the message at once and its model answers afterwards; Cortad grades the model's last answer to each message." : null,
74
+ sessionLine(v),
75
+ accountsLine(v.accounts),
76
+ ...(v.checks ?? []).map((said) => `Cortad's own check on this endpoint: ${said}`),
77
+ rulesLine(v.rules),
78
+ v.tools?.length ? `Tools that ran: ${v.tools.join(", ")}.` : null,
79
+ v.passages ? `Passages handed to the model: ${num(v.passages)}.` : null,
80
+ ...(v.problems ?? []).map((p) => `Problem${where(p) ? ` at ${where(p)}` : ": "}${p.said}`),
81
+ sampleLine(v.sample),
82
+ ];
83
+ return [...lines, ...inner.filter(Boolean).map((l) => ` ${l}`)];
84
+ }
85
+
86
+ // The time is said at one request at a time, the pace every run starts at; a door the run widens
87
+ // finishes sooner. While the read is still writing trials the counts are what it has so far.
88
+ function readyLine(r) {
89
+ if (!r || !r.trials) return null;
90
+ const soFar = r.reading ? " so far" : "";
91
+ const fit = has(r.planned) && r.planned > r.trials ? `${num(r.trials)} of the ${plural(r.planned, "trial")} written${soFar}, the ones that fit in one run` : `${plural(r.trials, "trial")}${soFar}`;
92
+ // The time at the widest the run goes, and at one request at a time when that differs: the run
93
+ // ramps up only while replies keep pace, and a one-at-a-time app holds it to one.
94
+ const one = r.atOne;
95
+ const slower = one && has(one.minutes) && (one.minutes !== r.minutes || one.trials < r.trials);
96
+ const time = has(r.minutes) && (slower
97
+ ? `about ${plural(r.minutes, "minute")} if your app answers several requests at once, or up to about ${plural(one.minutes, "minute")}${one.trials < r.trials ? ` and ${plural(one.trials, "trial")}` : ""} if it answers one at a time`
98
+ : `about ${plural(r.minutes, "minute")}`);
99
+ const usd = (x) => `$${x.toFixed(2)}`;
100
+ const cost = has(r.usd) && `about ${usd(r.usd)}${has(r.usdHigh) ? ` to ${usd(r.usdHigh)}` : ""}${soFar} on your ${r.provider ? `${r.provider} ` : ""}key${r.model ? ` for ${r.model}` : ""}${r.estimated ? ", estimated from the length of the text since your provider sent no token counts" : ""}`;
101
+ const parts = [fit, has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, time, cost].filter(Boolean);
102
+ const rules = !has(r.rules) ? "" : r.rules === 0 ? " No rules of your own were read from the code, so the run checks replies against generic standards." : ` Replies are checked against the ${plural(r.rules, "rule")} read from your code.`;
103
+ return `A run on the proven endpoints: ${parts.join(", ")}.${r.reading ? " The read is still adding trials; the run waits for it and plays them all." : ""}${rules}`;
104
+ }
105
+
106
+ // `data`: what the command did with the app's stores and services, said right under the run it is
107
+ // about, so what a run writes into for real is read before Run is pressed.
108
+ export function contactLines(c, data = []) {
109
+ const said = data.map((s) => `Data: ${s}`);
110
+ if (!c) return said;
111
+ const proven = c.proven ?? [];
112
+ const lines = proven.length ? [`Endpoints your own requests proved (${num(proven.length)}):`, ...proven.flatMap(doorLines)] : ["No request has reached the app's model yet."];
113
+ const missing = c.notCalled ?? [];
114
+ if (missing.length) lines.push(`Endpoints the read found that no request has reached (${num(missing.length)}):`, ...missing.map((m) => ` ${m.method} ${m.path}${m.file ? ` ${at(m.file, m.line)}` : ""}${m.excluded ? ` ${m.excluded}` : ""}`));
115
+ const ready = readyLine(c.ready);
116
+ if (ready) lines.push(ready);
117
+ for (const b of c.ready?.blocked ?? []) lines.push(`Run waits${sideOf(b.side)}: ${b.door}: ${b.why}${b.question ? ` ${b.question}` : ""}`);
118
+ return [...lines, ...said];
119
+ }
package/lib/data.mjs CHANGED
@@ -2,24 +2,21 @@
2
2
  // records by the hundred; written into the file your app keeps its real data in, they stay there
3
3
  // after the run. So an app started here runs against a copy of its database file, made in this
4
4
  // command's own temp folder and pointed at through the same variable, and the original is never
5
- // opened. What cannot be copied (a database server, a file your code names with no variable in
6
- // front of it, an app that was already running) is said before the first run, in one sentence.
5
+ // opened. What cannot be copied (a file your code names with no variable in front of it, an app that
6
+ // was already running) is said before the first run, in one sentence. A database server is copied
7
+ // on its own server (lib/stores.mjs).
7
8
  import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
8
9
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { storesOf } from "./stores.mjs";
9
11
 
10
12
  const FILE_DB = /\.(?:db|sqlite3?|duckdb)$/i;
11
13
  // sqlite:///x.db (SQLAlchemy, relative), sqlite:////abs/x.db, sqlite+aiosqlite:///x.db, file:./dev.db (Prisma, libsql).
12
14
  const FILE_URL = /^(sqlite(?:\+\w+)?:\/\/\/|file:(?:\/\/(?=\/))?)([^?]*)(\?.*)?$/i;
13
- const SERVER_URL = /^(postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|mssql|sqlserver|cockroachdb)(?:\+\w+)?:\/\//i;
14
- const ENGINE = { postgres: "Postgres", postgresql: "Postgres", cockroachdb: "CockroachDB", mysql: "MySQL", mariadb: "MariaDB", mongodb: "MongoDB", "mongodb+srv": "MongoDB", mssql: "SQL Server", sqlserver: "SQL Server" };
15
15
 
16
- // What one value names: a database file (with how to write the value again for another path), a
17
- // database server, or nothing.
16
+ // What one value names: a database file, with how to write the value again for another path, or nothing.
18
17
  export function databaseOf(value) {
19
18
  const v = String(value ?? "").trim();
20
19
  if (!v) return null;
21
- const server = SERVER_URL.exec(v);
22
- if (server) return { server: ENGINE[server[1].toLowerCase()] ?? server[1] };
23
20
  const url = FILE_URL.exec(v);
24
21
  if (url) {
25
22
  const path = decodeURIComponent(url[2]);
@@ -56,7 +53,7 @@ const sizeOf = (dir, cap) => {
56
53
  const DEFAULTED = /(?:os\.(?:environ\.get|getenv)|\bgetenv)\(\s*['"]([A-Za-z_]\w*)['"]\s*,\s*['"]([^'"\n]+)['"]|process\.env(?:\.([A-Za-z_]\w*)|\[\s*['"]([A-Za-z_]\w*)['"]\s*\])\s*(?:\?\?|\|\|)\s*['"]([^'"\n]+)['"]/g;
57
54
  // A database file opened by a path written in the code, with no variable in front of it.
58
55
  const OPENED = /(?:sqlite3\.connect|aiosqlite\.connect|duckdb\.connect|new\s+(?:sqlite3\.)?Database|create_(?:async_)?engine)\(\s*['"]([^'"\n]+)['"]/g;
59
- const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
56
+ export const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
60
57
  // A test opens its own scratch file; that is not where the app keeps its data.
61
58
  export const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
62
59
  // A folder bigger than this is not copied: said instead, so the person decides.
@@ -72,7 +69,7 @@ function scan(sources) {
72
69
  for (const m of text.matchAll(DEFAULTED)) {
73
70
  const name = m[1] ?? m[3] ?? m[4];
74
71
  const value = m[2] ?? m[5];
75
- if (name && !(name in defaults) && databaseOf(value)) defaults[name] = value;
72
+ if (name && !(name in defaults) && (databaseOf(value) || storesOf({ [name]: value }).length)) defaults[name] = value;
76
73
  }
77
74
  for (const m of text.matchAll(OPENED)) { const db = databaseOf(m[1]); if (db?.path) opened.push({ file, path: db.path }); }
78
75
  }
@@ -90,7 +87,8 @@ function located(db, dirs, appDir) {
90
87
  }
91
88
 
92
89
  // values: the environment the app will start with, names to values. from: the env file each name
93
- // was read from, when there is one. Returns the variables to start the app with, and what to say.
90
+ // was read from, when there is one. Returns the variables to start the app with, what to say, and
91
+ // the settings with the code's own defaults beside them, which the stores are read from.
94
92
  export function keepData({ values, from = {}, appDir, root = appDir, work, sources = [], started = true }) {
95
93
  const { defaults, opened } = scan(sources);
96
94
  const all = { ...defaults, ...values };
@@ -114,10 +112,6 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
114
112
  continue;
115
113
  }
116
114
  if (!db) continue;
117
- if (db.server) {
118
- said.push(`Trials write into the ${db.server} database ${name} names: a database server cannot be copied here, so what a run creates stays in it. Point ${name} at a scratch database before a run if that one holds real data.`);
119
- continue;
120
- }
121
115
  const original = located(db, from[name] ? [dirname(from[name])] : [], appDir);
122
116
  moved.add(original);
123
117
  if (!started) {
@@ -139,5 +133,5 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
139
133
  moved.add(cwdPath);
140
134
  said.push(`Your app opens ${o.path} by a path written in ${rel(o.file)}, with no variable to point it at a copy: trials write into that file.`);
141
135
  }
142
- return { env, copied, said };
136
+ return { env, copied, said, values: all };
143
137
  }