cortad 0.3.9 → 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/README.md CHANGED
@@ -39,9 +39,12 @@ The hooks run after an edit and on your next message: the first names the prompt
39
39
  --explain show what would be sent and started, then exit
40
40
  --port 3000 use an app that is already running
41
41
  --start "make dev" how your app starts
42
+ --data .env.staging copy the databases this settings file names, not the ones your app's settings name
42
43
  --verbose print your app's output
43
44
  ```
44
45
 
46
+ Your app runs on copies of its databases, made when it starts and deleted when the command ends; the originals are only read. A database it uses that cannot be copied holds Run until you say yes to writing into it.
47
+
45
48
  ## Files it creates
46
49
 
47
50
  - `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up, your app's output in `app.log`, the run it asked for last, in `news.txt` the line a finished run left for your agent, removed once the agent has it, and in `heard.txt` the ids of the runs your agent has read
package/lib/cli.mjs CHANGED
@@ -25,7 +25,7 @@ const WAITER = "run-when-up";
25
25
  // How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
26
26
  // about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
27
27
  const RESTART_MS = { mcp: 45_000, shell: 150_000 };
28
- export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field"];
28
+ export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field", "world", "world_status", "test"];
29
29
  export const COMMANDS = new Set(["mcp", ...VERBS, "wait", "news", "run-status", "field-connect", "stick", "unstick", WAITER]);
30
30
  // A lowercase word this version has no command for, so it is never read as a connect code.
31
31
  export const unknownVerb = (word) => /^[a-z][a-z_]*$/.test(word ?? "") && !COMMANDS.has(word);
@@ -161,6 +161,9 @@ export function argsOf(command, rest) {
161
161
  return { about: words[0], kind: words[1], needed: words.slice(2).join(" "), ...(got ? { got } : {}), ...(tried ? { tried } : {}) };
162
162
  }
163
163
  case "field": return rest[0] ? { days: rest[0] } : {};
164
+ case "world": return rest.length ? { ask: rest.join(" ") } : {};
165
+ case "test": return rest.length ? { ask: rest.join(" ") } : {};
166
+ case "world_status": return rest[0] ? { jobId: rest[0] } : {};
164
167
  case "reach": {
165
168
  const paths = rest.filter((w) => w.startsWith("/"));
166
169
  return paths.length ? { paths } : {};
@@ -0,0 +1,202 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ // Their commit history, read here and sent after the code: every commit since the newest one Cortad
4
+ // holds, the whole history the first time, oldest first, each with its message, author, dates, the
5
+ // paths it moved and a diff cut to the files this program would upload anyway, with any line that
6
+ // carries a key replaced before it leaves. One connect sends up to a budget; a longer history goes
7
+ // on at the next connect from where this one stopped. Git never reaches the network for this: lazy
8
+ // fetching is off and every call has a time limit, so a blobless clone sends messages alone.
9
+
10
+ export const LIMITS = {
11
+ // Bytes of history one connect sends; the newer commits past it follow at the next connect.
12
+ budget: 64_000_000,
13
+ // Commits whose diffs one git call reads.
14
+ window: 200,
15
+ // A diff larger than this, after filtering, is reported by size and not sent.
16
+ diffBytes: 256_000,
17
+ // One request to the server, under its limits.
18
+ partBytes: 1_500_000,
19
+ partCommits: 500,
20
+ messageChars: 4000,
21
+ paths: 2000,
22
+ };
23
+
24
+ const SHA = /^[0-9a-f]{40}$/;
25
+ const HEADER = /[0-9a-f]{40}\x1f/;
26
+ const FORMAT = "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%B%x1e";
27
+ // GIT_NO_LAZY_FETCH is read by git 2.44 and later; the time limit covers the rest.
28
+ const ENV = { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
29
+
30
+ const git = (root, args, timeout = 30_000) =>
31
+ execFileSync("git", ["-C", root, ...args], { env: ENV, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024, timeout }).toString("utf8");
32
+
33
+ export function headOf(root) {
34
+ try { const sha = git(root, ["rev-parse", "HEAD"]).trim(); return SHA.test(sha) ? sha : null; } catch { return null; }
35
+ }
36
+
37
+ // Everything after `since` while it is still behind HEAD; the whole history when nothing is held or
38
+ // history was rewritten under it.
39
+ export function rangeOf(root, since) {
40
+ if (since && SHA.test(since)) {
41
+ try { git(root, ["merge-base", "--is-ancestor", since, "HEAD"]); return `${since}..HEAD`; } catch { /* unknown here, or rewritten */ }
42
+ }
43
+ return "HEAD";
44
+ }
45
+
46
+ // A rename as numstat writes it, read as the path it ended at: "{a => b}/x", "a => b".
47
+ export const renamedTo = (path) => path.replace(/\{([^{}]*) => ([^{}]*)\}/g, "$2").replace(/^[^{]* => /, "").replace(/\/{2,}/g, "/");
48
+
49
+ function statsOf(text) {
50
+ const changed = [];
51
+ for (const line of text.split("\n")) {
52
+ const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
53
+ if (m) changed.push({ path: renamedTo(m[3]), added: m[1] === "-" ? null : Number(m[1]), deleted: m[2] === "-" ? null : Number(m[2]) });
54
+ }
55
+ return changed;
56
+ }
57
+
58
+ // The commits of a range, oldest first, each with the paths it moved that `keep` allows; the rest
59
+ // are counted as hidden. One git call for everything; when the stats cannot be read without the
60
+ // network, the commits are sent without them.
61
+ export function listCommits(root, range, { keep }) {
62
+ let out;
63
+ let withStats = true;
64
+ try { out = git(root, ["log", FORMAT, "--numstat", "-M", range], 120_000); } catch {
65
+ withStats = false;
66
+ try { out = git(root, ["log", FORMAT, range], 120_000); } catch { return { commits: [], withStats: false }; }
67
+ }
68
+ const segments = out.split("\x1e");
69
+ const commits = [];
70
+ let header = segments[0] ?? "";
71
+ for (let i = 1; i <= segments.length; i += 1) {
72
+ const rest = segments[i] ?? "";
73
+ const at = rest.search(HEADER);
74
+ const stats = at < 0 ? rest : rest.slice(0, at);
75
+ const [sha, parents = "", name = "", email = "", authoredAt = "", committedAt = "", ...body] = header.replace(/^\n+/, "").split("\x1f");
76
+ if (SHA.test(sha ?? "")) {
77
+ const all = statsOf(stats);
78
+ const shown = all.filter((c) => keep(c.path));
79
+ commits.push({
80
+ sha,
81
+ parents: parents.split(" ").filter((p) => SHA.test(p)),
82
+ author: { name: name.slice(0, 200), email: email.slice(0, 320) },
83
+ authoredAt, committedAt,
84
+ message: body.join("\x1f").trimEnd().slice(0, LIMITS.messageChars),
85
+ stats: { files: all.length, insertions: all.reduce((n, c) => n + (c.added ?? 0), 0), deletions: all.reduce((n, c) => n + (c.deleted ?? 0), 0) },
86
+ changed: shown.slice(0, LIMITS.paths),
87
+ hidden: all.length - shown.length,
88
+ stripped: 0,
89
+ });
90
+ }
91
+ if (at < 0) break;
92
+ header = rest.slice(at);
93
+ }
94
+ commits.reverse();
95
+ return { commits, withStats };
96
+ }
97
+
98
+ // git writes a path with unusual characters C-quoted; a plain one as it is.
99
+ const unquote = (path) => (path.startsWith('"') && path.endsWith('"') ? JSON.parse(path) : path);
100
+
101
+ // The two paths of one file's diff: before and after. A rename with no edit has only the rename lines.
102
+ function pathsOf(file) {
103
+ const line = (re) => { const m = re.exec(file); return m ? unquote(m[1]) : null; };
104
+ const head = /^diff --git a\/(.*) b\/(.*)$/m.exec(file);
105
+ const a = line(/^--- a\/(.*)$/m) ?? line(/^rename from (.*)$/m) ?? line(/^copy from (.*)$/m) ?? (head ? unquote(head[1]) : null);
106
+ const b = line(/^\+\+\+ b\/(.*)$/m) ?? line(/^rename to (.*)$/m) ?? line(/^copy to (.*)$/m) ?? (head ? unquote(head[2]) : null);
107
+ return { a, b };
108
+ }
109
+
110
+ const HELD = "[line held back on this machine: it carries a key]";
111
+
112
+ // A patch cut to the files `keep` allows, every added or removed line that carries a key replaced.
113
+ // A patch that grows past the limit is given up at that point: `over` says so, and no line of it
114
+ // is read for keys, since the key detector works line by line and a generated file's one long
115
+ // line would cost minutes.
116
+ export function filterDiff(patch, keep, holdsKey, limit = LIMITS.diffBytes) {
117
+ let stripped = 0;
118
+ let bytes = 0;
119
+ const kept = [];
120
+ for (const file of patch.split(/^(?=diff --git )/m)) {
121
+ if (!file.startsWith("diff --git ")) continue;
122
+ const { a, b } = pathsOf(file);
123
+ if (!a || !b || !keep(a) || !keep(b)) continue;
124
+ bytes += Buffer.byteLength(file);
125
+ if (bytes > limit) return { diff: "", stripped: 0, over: true, bytes: Buffer.byteLength(patch) };
126
+ kept.push(file.split("\n").map((line) => {
127
+ const sign = line[0];
128
+ if ((sign === "+" || sign === "-") && !/^(\+\+\+|---) /.test(line) && holdsKey(line.slice(1))) { stripped += 1; return sign + HELD; }
129
+ return line;
130
+ }).join("\n"));
131
+ }
132
+ const diff = kept.join("");
133
+ return { diff, stripped, over: false, bytes: Buffer.byteLength(diff) };
134
+ }
135
+
136
+ // These commits' diffs in one git call, by sha. A diff over the limit is reported by size and not
137
+ // carried. A merge carries none, as in git log.
138
+ export function diffsOf(root, shas, { keep, holdsKey }) {
139
+ const diffs = new Map();
140
+ if (!shas.length) return diffs;
141
+ let out;
142
+ try { out = git(root, ["log", "--no-walk=unsorted", "-p", "--no-color", "--no-ext-diff", "-M", "--format=%x1eCOMMIT %H", ...shas]); } catch { return diffs; }
143
+ for (const chunk of out.split("\x1eCOMMIT ")) {
144
+ const sha = chunk.slice(0, 40);
145
+ if (!SHA.test(sha)) continue;
146
+ const { diff, stripped, over, bytes } = filterDiff(chunk.slice(41), keep, holdsKey);
147
+ diffs.set(sha, over || !diff ? { bytes, stripped } : { bytes, stripped, diff });
148
+ }
149
+ return diffs;
150
+ }
151
+
152
+ // A remote address with any credential removed; an scp-like address carries none.
153
+ export function safeRemote(url) {
154
+ if (!url) return null;
155
+ try { const u = new URL(url); return `${u.protocol}//${u.host}${u.pathname}`.slice(0, 300); } catch { return /^[\w.-]+@[\w.-]+:/.test(url) ? url.slice(0, 300) : null; }
156
+ }
157
+
158
+ // What the repository is: where it pushes, which branch, how many have written to it, when it began.
159
+ export function repoFacts(root) {
160
+ const read = (args, f = (s) => s.trim()) => { try { return f(git(root, args)); } catch { return null; } };
161
+ const facts = {
162
+ remote: safeRemote(read(["config", "--get", "remote.origin.url"])),
163
+ branch: read(["rev-parse", "--abbrev-ref", "HEAD"], (s) => s.trim().slice(0, 200)),
164
+ contributors: read(["log", "-5000", "--format=%ae"], (s) => new Set(s.split("\n").filter(Boolean)).size),
165
+ firstCommitAt: read(["log", "--format=%cI"], (s) => s.trim().split("\n").at(-1) || null),
166
+ };
167
+ return Object.fromEntries(Object.entries(facts).filter(([, v]) => v !== null && v !== undefined));
168
+ }
169
+
170
+ // The commits to send, oldest first, each with its diff, up to this connect's budget, cut into
171
+ // parts under the server's limits.
172
+ export function historyParts(root, since, { keep, holdsKey }) {
173
+ const { commits } = listCommits(root, rangeOf(root, since), { keep });
174
+ const sent = [];
175
+ let spent = 0;
176
+ for (let i = 0; i < commits.length && spent < LIMITS.budget; i += LIMITS.window) {
177
+ const window = commits.slice(i, i + LIMITS.window);
178
+ const diffs = diffsOf(root, window.map((c) => c.sha), { keep, holdsKey });
179
+ for (const c of window) {
180
+ const held = diffs.get(c.sha);
181
+ if (held) {
182
+ c.stripped = held.stripped;
183
+ c.diffBytes = held.bytes;
184
+ if (held.diff) c.diff = held.diff;
185
+ }
186
+ spent += JSON.stringify(c).length;
187
+ if (spent > LIMITS.budget) break;
188
+ sent.push(c);
189
+ }
190
+ }
191
+ const parts = [];
192
+ let part = [];
193
+ let bytes = 0;
194
+ for (const c of sent) {
195
+ const size = JSON.stringify(c).length;
196
+ if (part.length && (bytes + size > LIMITS.partBytes || part.length === LIMITS.partCommits)) { parts.push(part); part = []; bytes = 0; }
197
+ part.push(c);
198
+ bytes += size;
199
+ }
200
+ if (part.length) parts.push(part);
201
+ return parts;
202
+ }
@@ -93,40 +93,21 @@ function doorLines(v) {
93
93
  return [...lines, ...inner.filter(Boolean).map((l) => ` ${l}`)];
94
94
  }
95
95
 
96
- // What a run would play, take and cost, said to the person: the conversations it starts with (of
97
- // those planned, the ones that fit in one run's time), the follow-ups apart, the time at the widest it
98
- // goes and what it costs on their own keys, or that no price could be read. "Written" was said of
99
- // conversations the run writes as it starts: "44 of the 79 trials written" beside "Trials: 39 written".
96
+ // What a run would play, take and cost, in one short line the agent hands the person: how many
97
+ // simulated users, about how long, and one price on their own key. A first run's price is what it is
98
+ // expected to cost (`expected`, from factors measured on recorded first runs); the most it could cost,
99
+ // the follow-ups and the pace at one request at a time were three more numbers nobody acted on.
100
100
  function readyLine(r) {
101
101
  if (!r || !r.trials) return null;
102
- const soFar = r.reading ? " so far" : "";
103
- const plays = has(r.planned) && r.planned > r.trials ? `${num(r.trials)} of the ${plural(r.planned, "conversation")} planned${soFar}, as many as fit in one run's time` : `${plural(r.trials, "conversation")}${soFar}`;
104
- // Follow-ups are played only where something breaks: said apart, never in the count above.
105
- const followUps = r.followUps ? `, and up to ${num(r.followUps)} more where something breaks` : "";
106
- // The run widens only while replies keep pace, and a one-at-a-time app holds it to one: the time is
107
- // a range when that is slower, and where fewer conversations fit at that pace, how many is said.
108
- const upTo = has(r.atOne?.minutes) && r.atOne.minutes > r.minutes ? r.atOne.minutes : null;
109
- const atOne = has(r.atOne?.trials) && r.atOne.trials < r.trials ? ` If your app answers one request at a time, about ${num(r.atOne.trials)} of them fit in that time.` : "";
110
- const minutes = has(r.minutes) && `in about ${upTo ? `${num(r.minutes)} to ${plural(upTo, "minute")}` : plural(r.minutes, "minute")}`;
111
- const size = [has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, minutes].filter(Boolean).join(" ");
112
- const usd = (x) => `$${x.toFixed(2)}`;
113
- // Some endpoints had no published rate or token counts: the price covers the others and says so.
114
- // A first run is priced at what it is expected to cost (`expected`, from factors measured on recorded
115
- // first runs) and the most it costs (`upTo`); a server from before `expected` sends the most alone.
116
- // After a run of the app's own, as what that run measured. An older server sends a range (`usdHigh`).
117
- // Where some endpoints had no price, on how many.
118
- const amount = () => `${r.upTo ? "up to" : "about"} ${usd(r.usd)}${has(r.usdHigh) ? ` to ${usd(r.usdHigh)}` : ""}`;
119
- const some = r.pricedOn ? `, on the ${num(r.pricedOn.endpoints)} of ${plural(r.pricedOn.of, "endpoint")} Cortad could price` : "";
120
- const onKey = `on your ${r.provider ? `${r.provider} ` : ""}key${r.model ? ` for ${r.model}` : ""}`;
121
- const both = r.upTo && has(r.expected);
122
- // No price is said, as the card says it (guide.ts quoteLine), never left out.
123
- const cost = has(r.usd)
124
- ? ` ${both ? `It is expected to cost ${usd(r.expected)}${soFar} ${onKey}, and the most it costs is ${usd(r.usd)}` : `It costs ${amount()}${soFar} ${onKey}`}${some}${r.estimated ? ", estimated from the length of the text since your provider sent no token counts" : ""}.`
125
- : " It runs on your app's own model keys; Cortad could not price it from what your app's model calls reported.";
126
- // No rules is a fact of a finished read: said while the code is still being read, it was false a minute later.
127
- const rules = !has(r.rules) || (r.reading && r.rules === 0) ? "" : r.rules === 0 ? " No rules of your own were read from the code, so replies are checked against general standards only." : ` Every reply is checked against the ${plural(r.rules, "rule")} read from your code.`;
128
- return `For the person: a run plays ${plays}${followUps}${size ? `: ${size}` : ""}.${atOne}${cost}${rules}${r.reading ? " Your code is still being read; the run waits for it and plays every conversation it adds." : ""}`;
102
+ const plays = `${plural(r.trials, "simulated user")}${r.reading ? " so far" : ""}`;
103
+ const minutes = has(r.minutes) ? ` in about ${plural(r.minutes, "minute")}` : "";
104
+ const key = `your ${r.provider ? `${r.provider} ` : ""}key`;
105
+ const price = has(r.usd) ? `About $${(r.upTo && has(r.expected) ? r.expected : r.usd).toFixed(2)} on ${key}.` : `It runs on ${key}.`;
106
+ return `A run plays ${plays}${minutes}. ${price}`;
129
107
  }
108
+ // No rules is a fact of a finished read, said before a run is spent on general standards alone: said
109
+ // while the code is still being read, it was false a minute later.
110
+ const noRulesLine = (r) => (r?.trials && r.rules === 0 && !r.reading ? "No rules of your own were read from the code, so replies are checked against general standards only." : null);
130
111
 
131
112
  // The endpoints no request has reached, as status lists them and reach sends them. `receipts`: doors
132
113
  // the App section says answered at once with their answer out of Cortad's sight, so not said here.
@@ -165,8 +146,7 @@ export function contactLines(c, data = [], receipts = []) {
165
146
  const lines = proven.length ? [head, ...proven.flatMap((v) => doorLines(alike ? { ...v, standing: null } : v))] : [];
166
147
  const missing = unreached(c, receipts);
167
148
  if (missing.length) lines.push(`Endpoints found in your code that have not answered yet (${num(missing.length)}):`, ...missing.map(unreachedLine), ...[reachLine(missing)].filter(Boolean));
168
- const ready = readyLine(c.ready);
169
- if (ready) lines.push(ready);
149
+ for (const line of [readyLine(c.ready), noRulesLine(c.ready)]) if (line) lines.push(line);
170
150
  // What holds Run is the server's next line (src/local/contact.ts heldLine), said once: printed here
171
151
  // as well, ulaim's hold on its Qdrant was read twice in one status.
172
152
  return [...lines, ...said];
@@ -0,0 +1,60 @@
1
+ // A store's whole server copied into a Docker container of ours for one session, for an app that
2
+ // names its databases or indexes in its code, so no setting can point it at a copy on the same
3
+ // server. Ours runs the image the original runs where the original is a container, else the
4
+ // engine's own image at the original's version. It listens on one port of this machine's loopback,
5
+ // the same inside the container and out, so an address the server gives of itself (a replica set's
6
+ // member, a search node's) is one the app reaches. It is removed with its volumes at the end.
7
+ import { execFile } from "node:child_process";
8
+ import { randomBytes } from "node:crypto";
9
+ import { createServer } from "node:net";
10
+
11
+ // `env` reaches the docker command only: `-e NAME` with no value hands a container that value
12
+ // without it ever sitting on a command line another process can read.
13
+ export const docker = (args, { env = {}, timeout = 120_000 } = {}) => new Promise((done) => {
14
+ execFile("docker", args, { timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, ...env } }, (err, out, errText) => done({ ok: !err, code: err?.code === "ENOENT" ? "missing" : err?.code ?? 0, out: String(out ?? ""), err: String(errText ?? err?.message ?? "") }));
15
+ });
16
+
17
+ let up = null;
18
+ export const dockerUp = () => (up ??= docker(["info", "--format", "{{.ServerVersion}}"], { timeout: 15_000 }).then((r) => r.ok));
19
+
20
+ export const freePort = () => new Promise((ok, no) => {
21
+ const s = createServer().once("error", no).listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => ok(port)); });
22
+ });
23
+
24
+ // The container publishing this port of the machine, with the network it sits on and its address
25
+ // there, which ours joins to read from it: a port published only on loopback is out of reach of
26
+ // another container on Linux.
27
+ export async function servedBy(port) {
28
+ const ps = await docker(["ps", "--format", "{{.ID}}\t{{.Image}}\t{{.Ports}}"], { timeout: 15_000 });
29
+ for (const line of ps.out.split("\n")) {
30
+ const [id, image, ports = ""] = line.split("\t");
31
+ const inner = new RegExp(`(?:^|,\\s*)\\S*:${port}->(\\d+)/tcp`).exec(ports)?.[1];
32
+ if (!inner) continue;
33
+ const nets = await docker(["inspect", "-f", "{{range $n, $e := .NetworkSettings.Networks}}{{$n}} {{$e.IPAddress}}\n{{end}}", id], { timeout: 15_000 });
34
+ const [net, ip] = nets.out.trim().split("\n")[0]?.split(" ") ?? [];
35
+ return { image, inner: Number(inner), ...(net && ip ? { net, ip } : {}) };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ export const copyName = (engine) => `cortad-${engine}-${process.pid}-${randomBytes(3).toString("hex")}`;
41
+
42
+ // Started detached; a first pull of the image is part of the start. Answers true when it runs.
43
+ // `secrets` reach the container by name only, their values through the docker command's environment.
44
+ export async function runCopy({ name, image, port, env = {}, secrets = {}, entrypoint, args = [], network }) {
45
+ const placed = network ? ["--network", network] : ["--add-host", "host.docker.internal:host-gateway"];
46
+ const settings = [...Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]), ...Object.keys(secrets).flatMap((k) => ["-e", k])];
47
+ return (await docker(["run", "-d", "--name", name, "--label", "cortad.copy=1", ...placed, "-p", `127.0.0.1:${port}:${port}`, ...settings, ...(entrypoint ? ["--entrypoint", entrypoint] : []), image, ...args], { env: secrets, timeout: 900_000 })).ok;
48
+ }
49
+
50
+ export const removeCopy = async (name) => {
51
+ const r = await docker(["rm", "-f", "-v", name], { timeout: 60_000 });
52
+ return r.ok || /No such container/i.test(r.err);
53
+ };
54
+
55
+ export async function until(ok, ms) {
56
+ for (const end = Date.now() + ms; Date.now() < end; await new Promise((r) => setTimeout(r, 1000))) {
57
+ try { if (await ok()) return true; } catch { /* not yet */ }
58
+ }
59
+ return false;
60
+ }
package/lib/data.mjs CHANGED
@@ -3,8 +3,9 @@
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
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).
6
+ // was already running) holds Run until the person says yes to writing into it. A database server is
7
+ // copied on its own server (lib/stores.mjs).
8
+ import { createHash } from "node:crypto";
8
9
  import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
9
10
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
10
11
  import { storesOf } from "./stores.mjs";
@@ -86,15 +87,20 @@ function located(db, dirs, appDir) {
86
87
  return places.find((p) => existsSync(p)) ?? places[0];
87
88
  }
88
89
 
90
+ // A file the run would write into as it is, as a row that holds Run: named to the API by its setting,
91
+ // or by the file's own name where the code opens it by path, and never by the path itself.
92
+ const heldFile = (path, name, why) => ({ id: createHash("sha256").update(`file:${path}`).digest("hex").slice(0, 16), names: [name.replace(/\W+/g, "_").replace(/^(?=\d)/, "_").slice(0, 100)], why });
93
+
89
94
  // values: the environment the app will start with, names to values. from: the env file each name
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.
95
+ // was read from, when there is one. Returns the variables to start the app with, what to say, what
96
+ // holds Run, and the settings with the code's own defaults beside them, which the stores are read from.
92
97
  export function keepData({ values, from = {}, appDir, root = appDir, work, sources = [], started = true }) {
93
98
  const { defaults, opened } = scan(sources);
94
99
  const all = { ...defaults, ...values };
95
100
  const env = {};
96
101
  const copied = [];
97
102
  const said = [];
103
+ const held = [];
98
104
  const moved = new Set();
99
105
  const rel = (p) => relative(root, p) || basename(p);
100
106
  for (const [name, value] of Object.entries(all)) {
@@ -102,8 +108,8 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
102
108
  const folder = db ? null : dataFolder(name, value, from[name] ? [dirname(from[name])] : [], appDir, root);
103
109
  if (folder) {
104
110
  moved.add(folder);
105
- if (!started) { said.push(`Your app was already running when this command started, so it writes to its own data folder, ${rel(folder)}/: test conversations write into it. Stop your app and run the command again to run against a copy.`); continue; }
106
- if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) { said.push(`Your data folder ${rel(folder)}/ (${name}) is over 1 GB, so it is not copied: test conversations write into it.`); continue; }
111
+ if (!started) { held.push(heldFile(folder, name, `${name} points at your data folder ${rel(folder)}/, and your app was already running when this command started, so test conversations would save into it. Stop your app, then run the connect command again.`)); continue; }
112
+ if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) { held.push(heldFile(folder, name, `${name} points at your data folder ${rel(folder)}/, which is over the 1 GB a run copies, so test conversations would save into it. Point ${name} at a smaller copy, then run the connect command again.`)); continue; }
107
113
  const to = join(work, "data", String(copied.length), basename(folder));
108
114
  cpSync(folder, to, { recursive: true });
109
115
  env[name] = to;
@@ -115,7 +121,7 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
115
121
  const original = located(db, from[name] ? [dirname(from[name])] : [], appDir);
116
122
  moved.add(original);
117
123
  if (!started) {
118
- said.push(`Your app was already running when this command started, so it writes to its own database, ${rel(original)}: test conversations write into it. Stop your app and run the command again to run against a copy.`);
124
+ held.push(heldFile(original, name, `${name} points at your database ${rel(original)}, and your app was already running when this command started, so test conversations would save into it. Stop your app, then run the connect command again.`));
119
125
  continue;
120
126
  }
121
127
  const to = join(work, "data", String(copied.length), basename(original));
@@ -131,7 +137,7 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
131
137
  const cwdPath = resolve(appDir, o.path);
132
138
  if ([path, cwdPath].some((p) => moved.has(p) || [...moved].some((m) => p.startsWith(m + "/")))) continue;
133
139
  moved.add(cwdPath);
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: test conversations write into that file.`);
140
+ held.push(heldFile(cwdPath, basename(o.path), `Your app opens ${o.path} by a path written in ${rel(o.file)}, with no setting to point it at a copy, so test conversations would save into that file. Read its path from a setting, then run the connect command again.`));
135
141
  }
136
- return { env, copied, said, values: all };
142
+ return { env, copied, said, held, values: all };
137
143
  }
@@ -0,0 +1,88 @@
1
+ // An Elasticsearch or OpenSearch server copied for a session into a container of ours
2
+ // (lib/container.mjs): every index the app can see, with its settings, mappings, aliases and
3
+ // documents, read over the server's own HTTP API and written into ours. The app names its indexes in
4
+ // its code, so it is the server that is copied, and the app is pointed at ours through its address.
5
+ import { randomBytes } from "node:crypto";
6
+ import { dockerUp, freePort, runCopy, servedBy, until } from "./container.mjs";
7
+
8
+ // A server holding more than this on its primaries is not copied: held instead, so the person decides.
9
+ export const ELASTIC_CAP = 1024 ** 3;
10
+ const PAGE = 500;
11
+ // Index settings the server makes for itself, or that tie the index to the original's cluster.
12
+ const OWN = new Set(["uuid", "creation_date", "creation_date_string", "version", "provided_name", "routing", "resize", "history", "verified_before_close", "blocks", "lifecycle", "replication", "frozen", "store", "shrink", "downsample"]);
13
+ const IMAGE = { elasticsearch: (v) => `docker.elastic.co/elasticsearch/elasticsearch:${v}`, opensearch: (v) => `opensearchproject/opensearch:${v}` };
14
+ // Each kind as one node that asks for no sign-in.
15
+ const SETTINGS = {
16
+ elasticsearch: { "xpack.security.enabled": "false", ES_JAVA_OPTS: "-Xms512m -Xmx512m" },
17
+ opensearch: { DISABLE_SECURITY_PLUGIN: "true", DISABLE_INSTALL_DEMO_CONFIG: "true", OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m" },
18
+ };
19
+ // A server the app signs in to is copied into one that asks for a password too, over plain HTTP on
20
+ // this machine's loopback.
21
+ const SECURED = { "xpack.security.enabled": "true", "xpack.security.http.ssl.enabled": "false", "xpack.security.transport.ssl.enabled": "false" };
22
+
23
+ const headersOf = (conn) => ({
24
+ "content-type": "application/json",
25
+ ...(conn.apiKey ? { authorization: `ApiKey ${conn.apiKey}` } : conn.user ? { authorization: `Basic ${Buffer.from(`${conn.user}:${conn.password ?? ""}`).toString("base64")}` } : {}),
26
+ });
27
+ async function call(conn, method, path, body, type) {
28
+ const res = await fetch(`${conn.base}${path}`, {
29
+ method,
30
+ headers: { ...headersOf(conn), ...(type ? { "content-type": type } : {}) },
31
+ body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
32
+ signal: AbortSignal.timeout(60_000),
33
+ });
34
+ const data = await res.json().catch(() => null);
35
+ if (!res.ok) throw Object.assign(new Error(String(res.status)), { why: res.status === 401 || res.status === 403 ? "auth" : "other" });
36
+ return data;
37
+ }
38
+ const whyOf = (e) => e?.why ?? (/ECONNREFUSED|ECONNRESET|fetch failed|timeout/i.test(String(e?.cause?.code ?? e?.message)) ? "down" : "other");
39
+ const at = (index) => `/${encodeURIComponent(index)}`;
40
+
41
+ const ownSettings = (index) => Object.fromEntries(Object.entries(index ?? {}).filter(([k]) => !OWN.has(k)).map(([k, v]) => [k, k === "number_of_replicas" ? "0" : v]));
42
+
43
+ // One index: made on ours as the original holds it, then its documents a page at a time.
44
+ async function copyIndex(from, to, index) {
45
+ const held = (await call(from, "GET", at(index)))[index];
46
+ await call(to, "PUT", at(index), { settings: { index: ownSettings(held.settings?.index) }, mappings: held.mappings ?? {}, aliases: held.aliases ?? {} });
47
+ let page = await call(from, "POST", `${at(index)}/_search?scroll=2m`, { size: PAGE, sort: ["_doc"] });
48
+ try {
49
+ while (page.hits.hits.length) {
50
+ const lines = page.hits.hits.flatMap((h) => [JSON.stringify({ index: { _index: index, _id: h._id, ...(h._routing ? { routing: h._routing } : {}) } }), JSON.stringify(h._source ?? {})]);
51
+ const done = await call(to, "POST", "/_bulk", `${lines.join("\n")}\n`, "application/x-ndjson");
52
+ if (done.errors) throw Object.assign(new Error("bulk"), { why: "other" });
53
+ page = await call(from, "POST", "/_search/scroll", { scroll: "2m", scroll_id: page._scroll_id });
54
+ }
55
+ } finally {
56
+ await call(from, "DELETE", "/_search/scroll", { scroll_id: page._scroll_id }).catch(() => {});
57
+ }
58
+ await call(to, "POST", `${at(index)}/_refresh`);
59
+ }
60
+
61
+ // Answers { port } or { why }. `conn`: base and port, and user and password or apiKey when the server asks.
62
+ export async function serveElastic(conn, name) {
63
+ let info, indexes;
64
+ try {
65
+ info = await call(conn, "GET", "/");
66
+ indexes = (await call(conn, "GET", "/_cat/indices?format=json&bytes=b&h=index,pri.store.size&expand_wildcards=open")).filter((i) => !i.index.startsWith("."));
67
+ } catch (e) { return { why: whyOf(e) }; }
68
+ if (indexes.reduce((n, i) => n + Number(i["pri.store.size"] ?? 0), 0) > ELASTIC_CAP) return { why: "big" };
69
+ const kind = info.version?.distribution === "opensearch" ? "opensearch" : "elasticsearch";
70
+ // An API key cannot be made again on another server, and OpenSearch keeps its users in a plugin.
71
+ if (conn.apiKey || (conn.user && kind === "opensearch")) return { why: "login" };
72
+ if (!(await dockerUp())) return { why: "docker" };
73
+ const image = (await servedBy(conn.port))?.image ?? IMAGE[kind](info.version?.number);
74
+ const ours = await freePort();
75
+ // The app's own user and password work on ours: the superuser's where it signs in as that, else
76
+ // a user of the same name made there.
77
+ const elastic = conn.user ? (conn.user === "elastic" ? conn.password : randomBytes(24).toString("hex")) : null;
78
+ // No shard is left unplaced on a laptop whose disk is nearly full.
79
+ const env = { ...SETTINGS[kind], ...(elastic ? SECURED : {}), "discovery.type": "single-node", "http.port": String(ours), "cluster.routing.allocation.disk.threshold_enabled": "false" };
80
+ if (!(await runCopy({ name, image, port: ours, env, secrets: elastic ? { ELASTIC_PASSWORD: elastic } : {} }))) return { why: "container" };
81
+ const to = { base: `http://127.0.0.1:${ours}`, ...(elastic ? { user: "elastic", password: elastic } : {}) };
82
+ if (!(await until(async () => Boolean(await call(to, "GET", "/_cluster/health?wait_for_status=yellow&timeout=1s")), 180_000))) return { why: "other" };
83
+ try {
84
+ if (elastic && conn.user !== "elastic") await call(to, "PUT", `/_security/user/${encodeURIComponent(conn.user)}`, { password: conn.password, roles: ["superuser"] });
85
+ for (const { index } of indexes) await copyIndex(conn, to, index);
86
+ } catch (e) { return { why: whyOf(e) }; }
87
+ return { port: ours };
88
+ }