cortad 0.3.1-rc.8 → 0.3.1
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 +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/cli.mjs +113 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -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 +33 -15
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1267 -153
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +467 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +321 -177
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +399 -480
- package/lib/verbs.mjs +142 -31
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +30 -1
- package/skill/SKILL.md +49 -42
- package/skill/references/results.md +279 -102
package/lib/mongo.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// A MongoDB database copied for a session on the server it lives on, with MongoDB's own tools: a
|
|
2
|
+
// dump of it restored under a name of ours, and dropped after. The address carries the password, so
|
|
3
|
+
// it reaches mongodump and mongorestore through a config file only this user can read, and mongosh
|
|
4
|
+
// through its environment: never a command line another process on this machine can read.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
// A database bigger than this is not copied: said instead, so the person decides.
|
|
11
|
+
export const MONGO_CAP = 1024 ** 3;
|
|
12
|
+
|
|
13
|
+
// The address with another database in it. A user kept in the app's own database signs in there,
|
|
14
|
+
// which the address says by naming that database, so the copy's address names it as the place to
|
|
15
|
+
// sign in. Rewritten as text: a replica set's host list (a:27017,b:27018) is no URL a parser takes.
|
|
16
|
+
export function withDb(uri, db, source) {
|
|
17
|
+
const [, head, hosts, path = "", query = "", tail = ""] = /^([^:]+:\/\/)([^/?#]*)(?:\/([^?#]*))?(\?[^#]*)?(#.*)?$/.exec(uri);
|
|
18
|
+
const cut = hosts.lastIndexOf("@");
|
|
19
|
+
const signsIn = cut > 0 && !hosts.startsWith(":") && !/[?&]authSource=/i.test(query);
|
|
20
|
+
const own = (() => { try { return decodeURIComponent(path); } catch { return path; } })();
|
|
21
|
+
const from = signsIn ? `${query ? `${query}&` : "?"}authSource=${encodeURIComponent(own || source || "admin")}` : query;
|
|
22
|
+
return `${head}${hosts}/${encodeURIComponent(db)}${from}${tail}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function run(bin, args, { env = {}, stdin = null } = {}) {
|
|
26
|
+
return new Promise((done) => {
|
|
27
|
+
const child = spawn(bin, args, { env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", ...env }, stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
28
|
+
let out = "", err = "";
|
|
29
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
30
|
+
child.stderr.on("data", (d) => { err = (err + d).slice(-4000); });
|
|
31
|
+
if (stdin) stdin.pipe(child.stdin);
|
|
32
|
+
child.on("error", (e) => done({ code: e.code === "ENOENT" ? "missing" : 1, out, err: String(e.message) }));
|
|
33
|
+
child.on("close", (code) => done({ code, out, err }));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// One statement against the server, the address in the environment.
|
|
37
|
+
const shell = (uri, script) => run("mongosh", ["--nodb", "--quiet", "--eval", `const c = new Mongo(process.env.CORTAD_MONGO); ${script}`], { env: { CORTAD_MONGO: uri } });
|
|
38
|
+
|
|
39
|
+
// Why a tool refused, as a class the sentence is written from, never in the server's own words.
|
|
40
|
+
export function refusal(r) {
|
|
41
|
+
if (r.code === "missing") return "missing";
|
|
42
|
+
if (/Authentication failed|AuthenticationFailed|auth error/i.test(r.err)) return "auth";
|
|
43
|
+
if (/not authorized|Unauthorized|requires authentication/i.test(r.err)) return "rights";
|
|
44
|
+
if (/ECONNREFUSED|connection refused|server selection|No servers available|timed out/i.test(r.err)) return "down";
|
|
45
|
+
return "other";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function cloneMongo(conn, source, clone) {
|
|
49
|
+
const size = await shell(withDb(conn.uri, source, source), `print(c.getDB(${JSON.stringify(source)}).stats().dataSize)`);
|
|
50
|
+
if (size.code !== 0) return { why: refusal(size) };
|
|
51
|
+
if (Number(size.out.trim()) > MONGO_CAP) return { why: "big" };
|
|
52
|
+
const dir = mkdtempSync(join(tmpdir(), "cortad-mongo-"));
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(join(dir, "from.yaml"), `uri: ${JSON.stringify(withDb(conn.uri, source, source))}\n`, { mode: 0o600 });
|
|
55
|
+
writeFileSync(join(dir, "to.yaml"), `uri: ${JSON.stringify(withDb(conn.uri, "", source))}\n`, { mode: 0o600 });
|
|
56
|
+
const dump = spawn("mongodump", [`--config=${join(dir, "from.yaml")}`, `--db=${source}`, "--archive"], { env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
57
|
+
let dumpErr = "";
|
|
58
|
+
dump.stderr.on("data", (d) => { dumpErr = (dumpErr + d).slice(-4000); });
|
|
59
|
+
const dumped = new Promise((done) => { dump.on("error", (e) => done(e.code === "ENOENT" ? "missing" : 1)); dump.on("close", done); });
|
|
60
|
+
const restored = await run("mongorestore", [`--config=${join(dir, "to.yaml")}`, "--archive", `--nsInclude=${source}.*`, `--nsFrom=${source}.*`, `--nsTo=${clone}.*`], { stdin: dump.stdout });
|
|
61
|
+
const dumpCode = await dumped;
|
|
62
|
+
if (dumpCode === 0 && restored.code === 0) return { how: "dump" };
|
|
63
|
+
await dropMongo(conn, clone, source);
|
|
64
|
+
// Either side ending stops the other, which then fails for that reason alone: the telling one wins.
|
|
65
|
+
const whys = [dumpCode === 0 ? null : refusal({ code: dumpCode, err: dumpErr }), restored.code === 0 ? null : refusal(restored)].filter(Boolean);
|
|
66
|
+
return { why: whys.find((w) => w !== "other") ?? "other" };
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function dropMongo(conn, clone, source) {
|
|
73
|
+
return (await shell(withDb(conn.uri, clone, source), `c.getDB(${JSON.stringify(clone)}).dropDatabase()`)).code === 0;
|
|
74
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { clip, closeToCall, has, num, pack, pageOf, plural } from "./words.mjs";
|
|
2
|
+
|
|
3
|
+
// Every number the run measured, whole, one call away (`findings numbers`): each group with its count
|
|
4
|
+
// by class, of how many, the interval on the share that matters, and one exchange from the worst
|
|
5
|
+
// group of each.
|
|
6
|
+
|
|
7
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
8
|
+
const interval = (b) => `${pct(b.lo)} to ${pct(b.hi)}`;
|
|
9
|
+
const isScale = (t) => t.classes.every((c) => /^\d+$/.test(c));
|
|
10
|
+
|
|
11
|
+
const units = (n, unit) => (unit === "replies" ? plural(n, "reply", "replies") : plural(n, "conversation"));
|
|
12
|
+
|
|
13
|
+
const SPLIT_WORDS = { journey: "By journey", segment: "By user segment", situation: "By place in your app", reply: "By reply" };
|
|
14
|
+
const SPLIT_WORD = { journey: "journey", segment: "user segment", situation: "place", reply: "reply" };
|
|
15
|
+
// The server counts in "trials"; the person reads conversations.
|
|
16
|
+
const unitSaid = (unit) => (unit === "replies" ? "replies" : "conversations");
|
|
17
|
+
|
|
18
|
+
// One group as a row under its header: the counts in the header's order, of how many, the average
|
|
19
|
+
// on a scale, then the share named with its interval.
|
|
20
|
+
function row(t, d, said) {
|
|
21
|
+
const of = units(d.of, t.unit);
|
|
22
|
+
const unsure = d.unsure ? `; ${closeToCall(d.unsure, said)}` : "";
|
|
23
|
+
const share = d.bad && d.of ? `, ${interval(d.bad)}` : "";
|
|
24
|
+
if (t.kind === "question") return `${num(d.counts?.broke ?? 0)} of ${of}${share}${unsure}`;
|
|
25
|
+
const counts = t.classes.map((c) => num(d.counts?.[c] ?? 0)).join(", ");
|
|
26
|
+
const mean = has(d.mean) ? `, average ${d.mean.toFixed(2)}` : "";
|
|
27
|
+
return `${counts} of ${of}${mean}${d.bad && t.badSaid ? `; ${num(d.bad.k)}${share}` : ""}${unsure}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// How the rows are read: a question's conversations broken, or a measurement's classes and its share.
|
|
31
|
+
const headerOf = (t) => (t.kind === "question"
|
|
32
|
+
? ` Counted as: the ${unitSaid(t.unit)} it broke in, of the ${unitSaid(t.unit)} that asked it, with the 95% interval.`
|
|
33
|
+
: ` Counted as: ${isScale(t) ? `levels ${t.classes.join(", ")}` : t.classes.join(", ")} of ${unitSaid(t.unit)}${t.badSaid ? `; then how many ${t.badSaid}, with the 95% interval` : ""}.`);
|
|
34
|
+
|
|
35
|
+
function tableBlock(t) {
|
|
36
|
+
const said = {};
|
|
37
|
+
const lines = [`${t.kind === "question" ? `${t.id} ` : ""}${clip(t.title, 400)}${t.from ? ` (${t.from})` : ""}`];
|
|
38
|
+
if (t.legend) lines.push(` ${clip(t.legend, 500)}`);
|
|
39
|
+
lines.push(headerOf(t), ` All: ${row(t, t.all, said)}`);
|
|
40
|
+
for (const split of ["journey", "segment", "situation", "reply"]) {
|
|
41
|
+
const groups = t.by?.[split] ?? [];
|
|
42
|
+
if (!groups.length || (split !== "reply" && groups.length === 1 && groups[0].of === t.all.of)) continue;
|
|
43
|
+
lines.push(` ${SPLIT_WORDS[split]}:`, ...groups.map((d) => ` ${clip(d.value, 90)}${d.kept ? " (saved to test fixes)" : ""}: ${row(t, d, said)}`));
|
|
44
|
+
}
|
|
45
|
+
const w = t.worst;
|
|
46
|
+
if (w) {
|
|
47
|
+
const e = w.exchange;
|
|
48
|
+
lines.push(` Worst: ${SPLIT_WORD[w.split] ?? w.split} ${clip(w.value, 90)}.${e ? ` Conversation ${String(e.trialId).slice(0, 8)}, reply ${e.reply}.` : " Its conversations are saved to test fixes."}`);
|
|
49
|
+
if (e?.sent) lines.push(` Sent: "${clip(e.sent, 300)}"`);
|
|
50
|
+
if (e?.answer) lines.push(` Reply ${e.reply}: "${clip(e.answer, 300)}"`);
|
|
51
|
+
if (e?.after) lines.push(` Then the customer: "${clip(e.after, 300)}"`);
|
|
52
|
+
if (e?.quote && e.quote !== e.answer) lines.push(` It broke on: "${clip(e.quote, 240)}"`);
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Every number, whole, in pages under what every client shows at once.
|
|
58
|
+
export function numbersText(d, page = 1) {
|
|
59
|
+
const n = d?.numbers;
|
|
60
|
+
if (!n) return d?.why ?? "No run has finished on this repository yet.";
|
|
61
|
+
const head = [
|
|
62
|
+
`Run ${n.runId}: every number it measured, ${plural(n.measures.length, "measurement")} and ${plural(n.questions.length, "question")} over ${plural(n.trials, "conversation")}, ${plural(n.replies, "reply", "replies")} and ${plural(n.readings, "reading")}.`,
|
|
63
|
+
"A conversation counts once in each group it played in; a reply split counts each conversation at that reply.",
|
|
64
|
+
].join("\n");
|
|
65
|
+
const blocks = [...n.measures, ...n.questions].map(tableBlock);
|
|
66
|
+
const pages = pack(head, blocks.map((b) => `\n${b}`), `Run ${n.runId}, numbers continued.`);
|
|
67
|
+
return pageOf(pages, page, (k) => `findings numbers with page ${k}`);
|
|
68
|
+
}
|
package/lib/pg.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A Postgres database copied for a session on the server it lives on, with the credentials the
|
|
2
|
+
// app's own settings carry, and dropped after. Postgres's own tools do the work (psql, and pg_dump
|
|
3
|
+
// when the database is in use); the password reaches them through their environment, never a
|
|
4
|
+
// command line another process on this machine can read.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
// A database bigger than this is not copied: said instead, so the person decides.
|
|
8
|
+
export const PG_CAP = 1024 ** 3;
|
|
9
|
+
const quote = (name) => `"${String(name).replace(/"/g, '""')}"`;
|
|
10
|
+
const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
|
|
11
|
+
|
|
12
|
+
const envOf = (conn, db) => ({
|
|
13
|
+
PATH: process.env.PATH ?? "",
|
|
14
|
+
HOME: process.env.HOME ?? "",
|
|
15
|
+
PGDATABASE: db,
|
|
16
|
+
PGCONNECT_TIMEOUT: "10",
|
|
17
|
+
PGAPPNAME: "cortad",
|
|
18
|
+
...(conn.host ? { PGHOST: conn.host } : {}),
|
|
19
|
+
...(conn.port ? { PGPORT: String(conn.port) } : {}),
|
|
20
|
+
...(conn.user ? { PGUSER: conn.user } : {}),
|
|
21
|
+
...(conn.password ? { PGPASSWORD: conn.password } : {}),
|
|
22
|
+
...(conn.sslmode ? { PGSSLMODE: conn.sslmode } : {}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function run(bin, args, env, input = null) {
|
|
26
|
+
return new Promise((done) => {
|
|
27
|
+
const child = spawn(bin, args, { env, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
28
|
+
let out = "", err = "";
|
|
29
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
30
|
+
child.stderr.on("data", (d) => { err = (err + d).slice(-4000); });
|
|
31
|
+
if (input) input.pipe(child.stdin);
|
|
32
|
+
child.on("error", (e) => done({ code: e.code === "ENOENT" ? "missing" : 1, out, err: String(e.message) }));
|
|
33
|
+
child.on("close", (code) => done({ code, out, err }));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One statement per -c: CREATE and DROP DATABASE refuse to run inside a transaction, and several
|
|
38
|
+
// statements in one string are one.
|
|
39
|
+
const psql = (conn, db, ...statements) =>
|
|
40
|
+
run("psql", ["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=verbose", ...statements.flatMap((s) => ["-c", s])], envOf(conn, db));
|
|
41
|
+
|
|
42
|
+
// Why psql refused, as a class the sentence is written from. Never the server's own words: they
|
|
43
|
+
// name the user and the host, and the sentence leaves this machine.
|
|
44
|
+
export function refusal(err) {
|
|
45
|
+
const code = /(?:ERROR|FATAL):\s+([0-9A-Z]{5}):/.exec(err)?.[1];
|
|
46
|
+
// A refusal while connecting carries no code, only the server's words.
|
|
47
|
+
if (code === "28P01" || code === "28000" || /password authentication failed|no pg_hba\.conf entry|role "[^"]*" does not exist|no password supplied/i.test(err)) return "auth";
|
|
48
|
+
if (code === "42501") return "rights";
|
|
49
|
+
if (code === "55006") return "busy";
|
|
50
|
+
if (code === "3D000" || /database "[^"]*" does not exist/i.test(err)) return "gone";
|
|
51
|
+
if (/server version mismatch|aborting because of server version/i.test(err)) return "version";
|
|
52
|
+
if (/Connection refused|could not connect|No such file or directory|timeout expired|could not translate host/i.test(err)) return "down";
|
|
53
|
+
return "other";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The database to stand in while a copy is made: never the source, which a template copy needs
|
|
57
|
+
// free of every connection, ours included.
|
|
58
|
+
async function maintenance(conn, source) {
|
|
59
|
+
for (const db of ["postgres", "template1"].filter((d) => d !== source)) {
|
|
60
|
+
const r = await psql(conn, db, "select 1");
|
|
61
|
+
if (r.code === 0) return { db };
|
|
62
|
+
if (r.code === "missing") return { why: "missing" };
|
|
63
|
+
const why = refusal(r.err);
|
|
64
|
+
if (why !== "gone") return { why };
|
|
65
|
+
}
|
|
66
|
+
return { why: "other" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The copy: a template copy first, which Postgres makes file by file; when the database is in use,
|
|
70
|
+
// which a template copy refuses, a dump of it restored into an empty one. Answers { how } or { why }.
|
|
71
|
+
export async function clonePg(conn, source, clone) {
|
|
72
|
+
const home = await maintenance(conn, source);
|
|
73
|
+
if (!home.db) return { why: home.why };
|
|
74
|
+
const size = await psql(conn, home.db, `select pg_database_size(${literal(source)})`);
|
|
75
|
+
if (size.code !== 0) return { why: refusal(size.err) };
|
|
76
|
+
if (Number(size.out.trim()) > PG_CAP) return { why: "big" };
|
|
77
|
+
const made = await psql(conn, home.db, `CREATE DATABASE ${quote(clone)} TEMPLATE ${quote(source)}`);
|
|
78
|
+
if (made.code === 0) return { how: "template" };
|
|
79
|
+
const why = refusal(made.err);
|
|
80
|
+
if (why !== "busy") return { why };
|
|
81
|
+
const empty = await psql(conn, home.db, `CREATE DATABASE ${quote(clone)}`);
|
|
82
|
+
if (empty.code !== 0) return { why: refusal(empty.err) };
|
|
83
|
+
const dump = spawn("pg_dump", ["--no-owner", "--no-privileges"], { env: envOf(conn, source), stdio: ["ignore", "pipe", "pipe"] });
|
|
84
|
+
let dumpErr = "";
|
|
85
|
+
dump.stderr.on("data", (d) => { dumpErr = (dumpErr + d).slice(-4000); });
|
|
86
|
+
const dumped = new Promise((done) => { dump.on("error", (e) => done(e.code === "ENOENT" ? "missing" : 1)); dump.on("close", done); });
|
|
87
|
+
const restored = await run("psql", ["-X", "-q", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=verbose"], envOf(conn, clone), dump.stdout);
|
|
88
|
+
const dumpCode = await dumped;
|
|
89
|
+
if (dumpCode === 0 && restored.code === 0) return { how: "dump" };
|
|
90
|
+
await dropPg(conn, clone);
|
|
91
|
+
if (dumpCode === "missing") return { why: "missing" };
|
|
92
|
+
// Either side ending stops the other, which then fails for that reason alone: the telling one wins.
|
|
93
|
+
const whys = [dumpCode === 0 ? null : refusal(dumpErr), restored.code === 0 ? null : refusal(restored.err)].filter(Boolean);
|
|
94
|
+
return { why: whys.find((w) => w !== "other") ?? "other" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// An empty database on a server of this machine, for a stand-in of one kept elsewhere: the app's own
|
|
98
|
+
// migrations fill it. Answers { how } or { why }.
|
|
99
|
+
export async function emptyPg(conn, name) {
|
|
100
|
+
const home = await maintenance(conn);
|
|
101
|
+
if (!home.db) return { why: home.why };
|
|
102
|
+
const made = await psql(conn, home.db, `CREATE DATABASE ${quote(name)}`);
|
|
103
|
+
return made.code === 0 ? { how: "empty" } : { why: refusal(made.err) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One migration file, run whole: a failed statement stops it.
|
|
107
|
+
export const applySql = async (conn, db, file) =>
|
|
108
|
+
(await run("psql", ["-X", "-q", "-v", "ON_ERROR_STOP=1", "-f", file], envOf(conn, db))).code === 0;
|
|
109
|
+
|
|
110
|
+
// The copy's own connections are ended first: the app is stopped by then, but a pool can outlive it.
|
|
111
|
+
export async function dropPg(conn, clone) {
|
|
112
|
+
const home = await maintenance(conn);
|
|
113
|
+
if (!home.db) return false;
|
|
114
|
+
const r = await psql(conn, home.db,
|
|
115
|
+
`select pg_terminate_backend(pid) from pg_stat_activity where datname = ${literal(clone)} and pid <> pg_backend_pid()`,
|
|
116
|
+
`DROP DATABASE IF EXISTS ${quote(clone)}`);
|
|
117
|
+
return r.code === 0;
|
|
118
|
+
}
|
package/lib/proc.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The app's processes: started tied to the runner, found by descent, stopped for certain, and the
|
|
2
|
+
// holder of a port named.
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const TETHER = fileURLToPath(new URL("./tether.mjs", import.meta.url));
|
|
10
|
+
|
|
11
|
+
// ps's etime, [[dd-]hh:]mm:ss, in milliseconds.
|
|
12
|
+
export function elapsedMs(etime) {
|
|
13
|
+
const [days, clock] = String(etime).includes("-") ? String(etime).split("-") : [0, String(etime)];
|
|
14
|
+
const [s = 0, m = 0, h = 0] = clock.split(":").map(Number).reverse();
|
|
15
|
+
return (((Number(days) * 24 + h) * 60 + m) * 60 + s) * 1000;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Every process descended from pid. By descent, not by process group: nodemon, pm2 and concurrently
|
|
19
|
+
// put the real server in a group of its own, and an app started through one of them was never seen
|
|
20
|
+
// to open its port.
|
|
21
|
+
export async function familyOf(pid) {
|
|
22
|
+
const table = (await exec("ps", ["-axo", "pid=,ppid="])).stdout.trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
|
|
23
|
+
const family = new Set([pid]);
|
|
24
|
+
for (let grew = true; grew;) { grew = false; for (const [p, parent] of table) if (family.has(parent) && !family.has(p)) { family.add(p); grew = true; } }
|
|
25
|
+
return [...family];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// TCP ports pid and its descendants listen on.
|
|
29
|
+
export async function listening(pid) {
|
|
30
|
+
try {
|
|
31
|
+
const { stdout } = await exec("lsof", ["-nP", "-a", "-p", (await familyOf(pid)).join(","), "-iTCP", "-sTCP:LISTEN", "-Fn"]);
|
|
32
|
+
return [...new Set([...stdout.matchAll(/^n.*:(\d+)$/gm)].map((m) => Number(m[1])).filter((p) => p > 0))];
|
|
33
|
+
} catch { return []; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
|
|
37
|
+
// while the parent is a runner, never into the person's own shell or terminal.
|
|
38
|
+
const RUNNER = /^(?:\S*\/)?(?:node|npm|npx|pnpm|yarn|bun|deno|python[\d.]*|uvicorn|gunicorn|flask|tsx|ts-node|nodemon|concurrently|pm2)(?:\s|$)|^(?:\/bin\/)?sh -c\b/;
|
|
39
|
+
export async function chainOf(pid) {
|
|
40
|
+
const rows = (await exec("ps", ["-axo", "pid=,ppid=,etime=,args="])).stdout.trim().split("\n").map((l) => l.trim());
|
|
41
|
+
const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), etime: m[3], args: m[4] }] : [0, null]; }));
|
|
42
|
+
const chain = table.get(pid) ? [table.get(pid)] : [];
|
|
43
|
+
for (let i = 0; i < 8 && chain.length; i++) {
|
|
44
|
+
const parent = table.get(chain.at(-1).ppid);
|
|
45
|
+
if (!parent || !RUNNER.test(parent.args)) break;
|
|
46
|
+
chain.push(parent);
|
|
47
|
+
}
|
|
48
|
+
return chain;
|
|
49
|
+
}
|
|
50
|
+
export const supervisorOf = async (pid) => (await chainOf(pid)).at(-1)?.pid ?? pid;
|
|
51
|
+
|
|
52
|
+
// The process listening on a port.
|
|
53
|
+
export async function listenerOn(port) {
|
|
54
|
+
try { return Number((await exec("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"])).stdout.trim().split("\n")[0]) || null; } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The port a bind failure names, in the app's own words: uvicorn's "('127.0.0.1', 8105): address
|
|
58
|
+
// already in use", Go's "0.0.0.0:8080: bind: address already in use", Node's "EADDRINUSE: address
|
|
59
|
+
// already in use :::3000" or "... 127.0.0.1:3000", Rails' "... for "127.0.0.1" port 3000". 0 when
|
|
60
|
+
// it names none.
|
|
61
|
+
export const portInError = (said) => Number(/[:\s(,](\d{4,5})\)?:?[^\n\d]{0,12}address already in use/i.exec(said)?.[1] ?? /(?:EADDRINUSE|address already in use)[^\n]{0,60}?[:\s](\d{4,5})\b/i.exec(said)?.[1] ?? 0);
|
|
62
|
+
|
|
63
|
+
// Whoever holds a port: its pid, the program and its first argument (never the rest of its command
|
|
64
|
+
// line, which can carry a key), and the folder it runs in.
|
|
65
|
+
export async function holderOf(port) {
|
|
66
|
+
const pid = await listenerOn(port);
|
|
67
|
+
if (!pid) return null;
|
|
68
|
+
const [args, cwd] = await Promise.all([
|
|
69
|
+
exec("ps", ["-o", "args=", "-p", String(pid)]).then((r) => r.stdout.trim(), () => ""),
|
|
70
|
+
exec("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]).then((r) => /^n(.+)$/m.exec(r.stdout)?.[1] ?? null, () => null),
|
|
71
|
+
]);
|
|
72
|
+
return { port, pid, command: args.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => basename(w)).join(" "), cwd };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// pid and everything it started, stopped: asked first, then made to. The group is signalled too, for
|
|
76
|
+
// what its leader started before it exited.
|
|
77
|
+
export async function stopTree(pid) {
|
|
78
|
+
const family = await familyOf(pid).catch(() => [pid]);
|
|
79
|
+
const signal = (sig) => { for (const p of family) { try { process.kill(p, sig); } catch { /* already gone */ } } try { process.kill(-pid, sig); } catch { /* no group left */ } };
|
|
80
|
+
const alive = () => family.some((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
|
|
81
|
+
signal("SIGTERM");
|
|
82
|
+
for (let i = 0; i < 15 && alive(); i++) await new Promise((r) => setTimeout(r, 200));
|
|
83
|
+
if (alive()) signal("SIGKILL");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A shell command in a process group of its own, stopped with this process however this process
|
|
87
|
+
// ends. A watchdog (lib/tether.mjs) holds the read end of a pipe from here and stops the command's
|
|
88
|
+
// tree when the pipe closes, which the kernel does for a process killed outright. The watchdog runs
|
|
89
|
+
// with a bare environment, so the hooks handed to the app never load into it.
|
|
90
|
+
export function spawnTied(cmd, { cwd, env }) {
|
|
91
|
+
const app = spawn("/bin/sh", ["-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
92
|
+
if (!app.pid) return app;
|
|
93
|
+
const dog = spawn(process.execPath, [TETHER, String(app.pid)], { stdio: ["pipe", "ignore", "ignore"], detached: true, env: { PATH: process.env.PATH ?? "" } });
|
|
94
|
+
dog.unref();
|
|
95
|
+
dog.stdin.unref?.();
|
|
96
|
+
dog.stdin.on("error", () => { /* the watchdog is gone; the runner stops the app itself */ });
|
|
97
|
+
// An app that ended while this process lives is this process's to clean up, not the watchdog's.
|
|
98
|
+
app.once("exit", () => { try { dog.kill("SIGKILL"); } catch { /* gone */ } });
|
|
99
|
+
return app;
|
|
100
|
+
}
|