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.
- package/README.md +1 -1
- package/lib/app-port.mjs +9 -0
- package/lib/backing.mjs +230 -0
- package/lib/cli.mjs +31 -18
- package/lib/contact-text.mjs +119 -0
- package/lib/data.mjs +10 -16
- package/lib/dissection-text.mjs +227 -0
- package/lib/env-secrets.mjs +23 -0
- package/lib/failed-text.mjs +3 -3
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +53 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +4 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +11 -10
- package/lib/mint.mjs +171 -36
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +65 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +782 -0
- package/lib/proxy.mjs +183 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +284 -0
- package/lib/pyhook/sitecustomize.py +952 -124
- package/lib/qdrant.mjs +47 -0
- package/lib/read-text.mjs +3 -3
- package/lib/redis.mjs +130 -0
- package/lib/replay.mjs +386 -33
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +1 -1
- package/lib/stores.mjs +341 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +223 -156
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +344 -558
- package/lib/verbs.mjs +47 -17
- package/lib/wire.cjs +818 -0
- package/lib/words.mjs +2 -0
- package/local.mjs +444 -271
- package/package.json +26 -1
- package/skill/SKILL.md +49 -42
- 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.
|
|
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
|
|
package/lib/app-port.mjs
ADDED
|
@@ -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, "");
|
package/lib/backing.mjs
ADDED
|
@@ -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,
|
|
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
|
|
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
|
|
86
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
if (app
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
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(
|
|
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
|
|
6
|
-
//
|
|
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
|
|
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,
|
|
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
|
}
|