cortad 0.3.1-rc.9 → 0.3.2
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/calls.mjs +23 -0
- package/lib/cli.mjs +115 -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 +52 -18
- 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 +1175 -166
- 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 +464 -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 +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- 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 +31 -1
- package/skill/SKILL.md +49 -42
- package/skill/references/results.md +279 -102
package/lib/qdrant.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A Qdrant collection copied for a session into a new collection of the same server, point by point
|
|
2
|
+
// over Qdrant's own HTTP API with the vectors, payloads and payload indexes it holds, and deleted
|
|
3
|
+
// after. The key, when the server has one, travels in its header.
|
|
4
|
+
export const QDRANT_CAP = 200_000;
|
|
5
|
+
const BATCH = 256;
|
|
6
|
+
const at = (name) => `/collections/${encodeURIComponent(name)}`;
|
|
7
|
+
|
|
8
|
+
async function call(conn, method, path, body) {
|
|
9
|
+
const res = await fetch(`${conn.base}${path}`, {
|
|
10
|
+
method,
|
|
11
|
+
headers: { "content-type": "application/json", ...(conn.apiKey ? { "api-key": conn.apiKey } : {}) },
|
|
12
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
13
|
+
signal: AbortSignal.timeout(30_000),
|
|
14
|
+
});
|
|
15
|
+
const data = await res.json().catch(() => null);
|
|
16
|
+
if (!res.ok) throw Object.assign(new Error(String(res.status)), { why: res.status === 401 || res.status === 403 ? "auth" : res.status === 404 ? "gone" : "other" });
|
|
17
|
+
return data?.result;
|
|
18
|
+
}
|
|
19
|
+
const whyOf = (e) => e?.why ?? (/ECONNREFUSED|fetch failed|timeout/i.test(String(e?.cause?.code ?? e?.message)) ? "down" : "other");
|
|
20
|
+
|
|
21
|
+
// A collection the server does not hold yet is one the app makes on first use: pointed at the copy's
|
|
22
|
+
// name, it makes that one instead, and it is deleted like any copy.
|
|
23
|
+
export async function cloneQdrant(conn, source, clone) {
|
|
24
|
+
let info;
|
|
25
|
+
try { info = await call(conn, "GET", at(source)); } catch (e) { return e?.why === "gone" ? { how: "empty" } : { why: whyOf(e) }; }
|
|
26
|
+
if ((info.points_count ?? 0) > QDRANT_CAP) return { why: "big" };
|
|
27
|
+
let made = false;
|
|
28
|
+
try {
|
|
29
|
+
const { vectors, sparse_vectors, on_disk_payload } = info.config.params;
|
|
30
|
+
await call(conn, "PUT", at(clone), { vectors, ...(sparse_vectors ? { sparse_vectors } : {}), ...(on_disk_payload === undefined ? {} : { on_disk_payload }) });
|
|
31
|
+
made = true;
|
|
32
|
+
for (const [field_name, schema] of Object.entries(info.payload_schema ?? {})) await call(conn, "PUT", `${at(clone)}/index?wait=true`, { field_name, field_schema: schema.params ?? schema.data_type });
|
|
33
|
+
for (let offset; ;) {
|
|
34
|
+
const page = await call(conn, "POST", `${at(source)}/points/scroll`, { limit: BATCH, with_payload: true, with_vector: true, ...(offset === undefined ? {} : { offset }) });
|
|
35
|
+
if (page.points.length) await call(conn, "PUT", `${at(clone)}/points?wait=true`, { points: page.points.map(({ id, vector, payload }) => ({ id, vector, payload })) });
|
|
36
|
+
offset = page.next_page_offset;
|
|
37
|
+
if (offset === null || offset === undefined) return { how: "points" };
|
|
38
|
+
}
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (made) await dropQdrant(conn, clone);
|
|
41
|
+
return { why: whyOf(e) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function dropQdrant(conn, clone) {
|
|
46
|
+
try { await call(conn, "DELETE", at(clone)); return true; } catch (e) { return e?.why === "gone"; }
|
|
47
|
+
}
|
package/lib/reach.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { isIPv4 } from "node:net";
|
|
2
|
+
import { unreached } from "./contact-text.mjs";
|
|
3
|
+
import { plural, upper } from "./words.mjs";
|
|
4
|
+
|
|
5
|
+
// One request to each endpoint status lists as not reached, from this machine to the app on its port.
|
|
6
|
+
// Each body is the one Cortad read that endpoint's client sending (the server's `request`).
|
|
7
|
+
// The connect command sends these by itself, one at a time, each tagged x-cortad-turn: reach:auto:<n>
|
|
8
|
+
// (sendOurs), signed in only as a test account this machine made. Headers are never the agent's: they
|
|
9
|
+
// go from here to the app on this machine and nowhere else, and only their names are printed. These
|
|
10
|
+
// are real requests with real effects.
|
|
11
|
+
|
|
12
|
+
// Under the 60 seconds Codex, Cursor CLI and Copilot CLI give an MCP call, with the status read first.
|
|
13
|
+
export const REACH_MS = 55_000;
|
|
14
|
+
const SAID_CHARS = 200;
|
|
15
|
+
const NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,100}$/;
|
|
16
|
+
const RUN_TURN = "x-cortad-turn";
|
|
17
|
+
const loopback = (host) => host === "::1" || (isIPv4(host) && host.startsWith("127."));
|
|
18
|
+
const keyOf = (m) => `${m.method} ${m.path}`;
|
|
19
|
+
const appUrl = (runner) => new URL(`http://${runner.host === "::1" ? "[::1]" : runner.host ?? "127.0.0.1"}:${runner.port}`);
|
|
20
|
+
|
|
21
|
+
// Why nothing can be sent to the app this record names, or null.
|
|
22
|
+
export function notUp(runner) {
|
|
23
|
+
if (runner?.state !== "up") {
|
|
24
|
+
return !runner ? "No connect command is running for this repository, so no app is up on this machine."
|
|
25
|
+
: runner.state === "failed" ? upper(runner.error) : "Your app is still starting.";
|
|
26
|
+
}
|
|
27
|
+
const host = runner.host ?? "127.0.0.1";
|
|
28
|
+
return loopback(host) ? null : `${host} is not this machine; reach sends only to your app on this machine.`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The rows a call sends and the ones it leaves: the named paths, else every row with a ready request
|
|
32
|
+
// that is not marked by hand.
|
|
33
|
+
export function picked(contact, receipts, paths = []) {
|
|
34
|
+
const rows = unreached(contact, receipts);
|
|
35
|
+
const named = new Set(Array.isArray(paths) ? paths : []);
|
|
36
|
+
const chosen = named.size ? rows.filter((m) => named.has(m.path) || named.has(keyOf(m))) : rows.filter((m) => !m.unsure);
|
|
37
|
+
const sent = chosen.filter((m) => m.request);
|
|
38
|
+
return {
|
|
39
|
+
rows, sent,
|
|
40
|
+
left: (named.size ? chosen : rows).filter((m) => !m.excluded && !sent.includes(m)),
|
|
41
|
+
unknown: [...named].filter((p) => !rows.some((m) => p === m.path || p === keyOf(m))),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// `runner`: this machine's record of the app (lib/runner.mjs); `contact`: status's section of the same name.
|
|
46
|
+
export async function reach({ runner, contact, headers = {}, paths = [], fetchImpl = fetch }) {
|
|
47
|
+
const down = notUp(runner);
|
|
48
|
+
if (down) return refused(down);
|
|
49
|
+
const sign = headersOf(headers);
|
|
50
|
+
if (sign.error) return refused(sign.error);
|
|
51
|
+
const pick = picked(contact, runner.receipts, paths);
|
|
52
|
+
const app = appUrl(runner);
|
|
53
|
+
const secrets = secretsIn(sign.headers);
|
|
54
|
+
const names = Object.keys(sign.headers);
|
|
55
|
+
// Without a test sign-in the request is Cortad's own, and tagged so.
|
|
56
|
+
let n = 0;
|
|
57
|
+
const headersFor = () => (names.length ? sign.headers : { [RUN_TURN]: `reach:agent:${++n}` });
|
|
58
|
+
const answers = await Promise.all(pick.sent.map((m) => sendOne(m, app, headersFor(), fetchImpl, secrets)));
|
|
59
|
+
return { text: reachText(pick, answers, `${runner.port} at once${names.length ? `, with the headers ${names.join(", ")}` : ""}`) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What a reach did, for the agent: each endpoint's status and time, and what was not sent.
|
|
63
|
+
export function reachText({ rows, sent, left, unknown }, answers, how) {
|
|
64
|
+
return [
|
|
65
|
+
sent.length ? `Sent ${plural(sent.length, "request")} to your app on port ${how}:`
|
|
66
|
+
: rows.length ? "Nothing was sent." : "Nothing was sent: status lists no endpoint that no request has reached.",
|
|
67
|
+
...answers.map((a) => ` ${a.line}`),
|
|
68
|
+
...(left.length ? ["Not sent:", ...left.map((m) => ` ${m.method} ${m.path}: ${whyNot(m)}`)] : []),
|
|
69
|
+
unknown.length ? `Not among the endpoints no request has reached: ${unknown.join(", ")}.` : "",
|
|
70
|
+
sent.length ? "status shows which of them reached your model." : "",
|
|
71
|
+
"next: status",
|
|
72
|
+
].filter(Boolean).join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Cortad's own test requests, sent by the connect command: one at a time, so what the app prints
|
|
76
|
+
// belongs to one of them, each with the app's own origin where one is known. An endpoint that answers
|
|
77
|
+
// 401 or 403 is asked once more as the test account this machine signed in, when there is one
|
|
78
|
+
// (`signIn(row)` gives { name, value } or null); the token never leaves this machine.
|
|
79
|
+
// `stop()`: true when the rest should wait, for the agent's ask; the answers so far come back.
|
|
80
|
+
// Four at a time: one each in turn left a 22-endpoint app waiting nearly two minutes behind its two
|
|
81
|
+
// slowest replies (ulaim, 2026-10-06). Answers keep the rows' order; a row is not sent once stop() says so.
|
|
82
|
+
const AT_ONCE = 4;
|
|
83
|
+
export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, fetchImpl = fetch, atOnce = AT_ONCE }) {
|
|
84
|
+
const app = appUrl(runner);
|
|
85
|
+
const base = () => ({ ...(origin ? { origin, referer: `${origin}/` } : {}), [RUN_TURN]: `reach:auto:${next()}` });
|
|
86
|
+
const answers = [];
|
|
87
|
+
let at = 0, stopped = false;
|
|
88
|
+
const one = async (m) => {
|
|
89
|
+
let got = await sendOne(m, app, base(), fetchImpl);
|
|
90
|
+
if (refusedVisitor(got)) {
|
|
91
|
+
const as = await signIn(m).catch(() => null);
|
|
92
|
+
if (as?.name && as.value) {
|
|
93
|
+
const again = await sendOne(m, app, { ...base(), [as.name]: as.value }, fetchImpl, secretsIn({ [as.name]: as.value }));
|
|
94
|
+
got = { ...again, signedIn: true, line: `${again.line} Sent as a test account.` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return got;
|
|
98
|
+
};
|
|
99
|
+
const lane = async () => {
|
|
100
|
+
while (at < rows.length) {
|
|
101
|
+
if (stopped || stop()) { stopped = true; return; }
|
|
102
|
+
const i = at++;
|
|
103
|
+
answers[i] = await one(rows[i]);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
await Promise.all(Array.from({ length: Math.min(atOnce, rows.length) }, lane));
|
|
107
|
+
// Rows never sent leave no hole: the answers stop where the first unsent row was.
|
|
108
|
+
const end = answers.findIndex((a) => a === undefined);
|
|
109
|
+
return end === -1 ? answers : answers.slice(0, end);
|
|
110
|
+
}
|
|
111
|
+
const refusedVisitor = (a) => a.status === 401 || a.status === 403;
|
|
112
|
+
|
|
113
|
+
// One line for the terminal per batch: "tested 6 endpoints: 5 answered, 1 needs a signed-in user",
|
|
114
|
+
// or null when none reached the app. A row never sent, or cut by a restart and sent again later, is
|
|
115
|
+
// no test; one refused as a test account too failed.
|
|
116
|
+
export function testedLine(answers) {
|
|
117
|
+
const tested = answers.filter((a) => !a.unsent && !a.down);
|
|
118
|
+
if (!tested.length) return null;
|
|
119
|
+
const answered = tested.filter((a) => a.ok).length;
|
|
120
|
+
const signIn = tested.filter((a) => refusedVisitor(a) && !a.signedIn).length;
|
|
121
|
+
const failed = tested.length - answered - signIn;
|
|
122
|
+
return `tested ${plural(tested.length, "endpoint")}: ${[
|
|
123
|
+
`${answered} answered`,
|
|
124
|
+
failed && `${failed} failed`,
|
|
125
|
+
signIn && `${signIn} ${signIn === 1 ? "needs" : "need"} a signed-in user`,
|
|
126
|
+
].filter(Boolean).join(", ")}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const refused = (why) => ({ text: `${why} Nothing was sent.\nnext: status`, isError: true });
|
|
130
|
+
|
|
131
|
+
const whyNot = (m) => m.byHand ?? (m.request ? "Cortad is not sure it reaches your model; name its path to reach to send it." : "Cortad has no request ready for it.");
|
|
132
|
+
|
|
133
|
+
// The headers as the caller passed them, checked at this edge: never echoed back, a bad one named only
|
|
134
|
+
// when its name is a header name, and the run's own header dropped.
|
|
135
|
+
export function headersOf(given) {
|
|
136
|
+
if (!given || typeof given !== "object" || Array.isArray(given)) return { error: "Headers are passed as names with their values." };
|
|
137
|
+
const headers = {};
|
|
138
|
+
for (const [name, value] of Object.entries(given)) {
|
|
139
|
+
if (!NAME.test(name) || typeof value !== "string") return { error: "Each header is passed as a name and a value." };
|
|
140
|
+
if (/[\r\n\0]/.test(value)) return { error: `The value of the header ${name} holds a line break.` };
|
|
141
|
+
if (name.toLowerCase() !== RUN_TURN) headers[name] = value;
|
|
142
|
+
}
|
|
143
|
+
return { headers };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// What the sign-in is made of, long enough to be a secret, so it is never printed even where the app
|
|
147
|
+
// writes it into its answer.
|
|
148
|
+
// ponytail: a value the app re-encodes before writing it back (URL or JSON escaping) is not matched.
|
|
149
|
+
const secretsIn = (headers) => [...new Set(Object.values(headers).flatMap((v) => [v, ...v.split(/[\s;,=]+/)]))]
|
|
150
|
+
.filter((s) => s.length >= 8).sort((a, b) => b.length - a.length);
|
|
151
|
+
const masked = (text, secrets) => secrets.reduce((t, s) => t.split(s).join("[sign-in]"), text);
|
|
152
|
+
|
|
153
|
+
// Sent only where the address, parsed, is still the app's own, and the listed endpoint's own: a path
|
|
154
|
+
// the server sent is never trusted to keep the host, nor to be the endpoint the line names. A request
|
|
155
|
+
// listed as POST /api/chat went to another address of the app with the person's sign-in while the
|
|
156
|
+
// line printed said /api/chat (the release review of 2026-10-05).
|
|
157
|
+
const sameRoute = (listed, sent) => {
|
|
158
|
+
const [a, b] = [String(listed).split("?")[0].split("/"), sent.split("/")];
|
|
159
|
+
return a.length === b.length && a.every((part, i) => (/^(?::|\{|\[|\*)/.test(part) ? b[i] !== "" : part === b[i]));
|
|
160
|
+
};
|
|
161
|
+
// What a message to a control route does is not a reply. The server never prepares one
|
|
162
|
+
// (src/local/unreached.ts sendFor, the same two patterns); the command does not take its word for it.
|
|
163
|
+
const ADMIN_CONTROL = /(?:^|\/)(?:admin|setup|settings?|configs?|configuration|billing|keys?|tokens?|users?|logout|import|export|uploads?|files?)(?:[-_/]|$|\?)/i;
|
|
164
|
+
const SETTINGS_WRITE = /(?:^|[/_-])(?:admin|setup|settings?|configs?|configuration|billing)(?:[-_/]|$|\?)/i;
|
|
165
|
+
export async function sendOne(m, app, headers, fetchImpl, secrets = []) {
|
|
166
|
+
const { method, path } = m.request;
|
|
167
|
+
const body = withImages(m.request.body);
|
|
168
|
+
const label = `${m.method} ${m.path}`;
|
|
169
|
+
const url = /^\/(?!\/)/.test(String(path)) ? new URL(`${app.origin}${path}`) : null;
|
|
170
|
+
if (url?.origin !== app.origin || !/^(?:GET|POST)$/.test(method)) return { line: `${label}: not sent, its request is not one to this app.`, ok: false, status: 0, unsent: true };
|
|
171
|
+
if (method !== m.method || !sameRoute(m.path, url.pathname)) return { line: `${label}: not sent, the request prepared for it goes to another address.`, ok: false, status: 0, unsent: true };
|
|
172
|
+
if (ADMIN_CONTROL.test(url.pathname) || (method !== "GET" && SETTINGS_WRITE.test(url.pathname))) return { line: `${label}: not sent, its address says it administers your app.`, ok: false, status: 0, unsent: true };
|
|
173
|
+
const json = body !== undefined && method === "POST";
|
|
174
|
+
const typed = Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
|
|
175
|
+
const started = performance.now();
|
|
176
|
+
const seconds = () => `${((performance.now() - started) / 1000).toFixed(1)} seconds`;
|
|
177
|
+
let res;
|
|
178
|
+
try {
|
|
179
|
+
res = await fetchImpl(url, { method, headers: json && !typed ? { "content-type": "application/json", ...headers } : headers,
|
|
180
|
+
...(json ? { body: JSON.stringify(body) } : {}), redirect: "manual", signal: AbortSignal.timeout(REACH_MS) });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return { line: `${label}: ${failedSaid(err, app.port || 80)}`, ok: false, status: 0, down: /^(?:ECONNREFUSED|ECONNRESET)$/.test(err?.cause?.code ?? err?.code ?? "") };
|
|
183
|
+
}
|
|
184
|
+
let text;
|
|
185
|
+
try { text = await res.text(); } catch { return { line: `${label}: ${res.status}, and the answer had not finished after ${REACH_MS / 1000} seconds.`, ok: false, status: res.status }; }
|
|
186
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
187
|
+
const said = ok ? "" : masked(text || res.headers.get("location") || "", secrets).replace(/\s+/g, " ").trim().slice(0, SAID_CHARS);
|
|
188
|
+
return { line: `${label}: ${res.status} in ${seconds()}.${said ? ` Your app answered: "${said}"` : ""}`, ok, status: res.status };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A real 64x64 PNG where the request carries an image and the server sent none: a made-up string in
|
|
192
|
+
// an image field is refused by the app before its model sees it. The same rule as the server's
|
|
193
|
+
// (src/local/unreached.ts withImages); a link, or an image already, stays.
|
|
194
|
+
const PNG_64 = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAS0lEQVR42u3PMQ0AAAwDoPo33UrYvQQckD4XAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAYHLAMpT0sIcNbcEAAAAAElFTkSuQmCC";
|
|
195
|
+
const IMAGE_FIELD = /(?:image|img|photo|picture|screenshot)s?(?:[_-]?(?:base64|b64|data|url|uri|src|file|content))?$/i;
|
|
196
|
+
const MIME_FIELD = /^(?:mime|mime_?type|content_?type|media_?type|image_?type)$/i;
|
|
197
|
+
const IMAGE_BYTES = [[0x89, 0x50, 0x4e, 0x47], [0xff, 0xd8, 0xff], [0x47, 0x49, 0x46], [0x52, 0x49, 0x46, 0x46]];
|
|
198
|
+
const isImage = (v) => {
|
|
199
|
+
const bytes = Buffer.from(v.replace(/^data:image\/[\w.+-]+;base64,/, ""), "base64");
|
|
200
|
+
return IMAGE_BYTES.some((sig) => sig.every((b, i) => bytes[i] === b));
|
|
201
|
+
};
|
|
202
|
+
export function withImages(value) {
|
|
203
|
+
if (Array.isArray(value)) return value.map(withImages);
|
|
204
|
+
if (!value || typeof value !== "object") return value;
|
|
205
|
+
const out = {};
|
|
206
|
+
let placed = false;
|
|
207
|
+
for (const [k, v] of Object.entries(value)) {
|
|
208
|
+
if (typeof v === "string" && IMAGE_FIELD.test(k) && !/^https?:\/\//i.test(v.trim()) && !isImage(v)) {
|
|
209
|
+
out[k] = /url|uri|src/i.test(k) || v.startsWith("data:") ? `data:image/png;base64,${PNG_64}` : PNG_64;
|
|
210
|
+
placed = true;
|
|
211
|
+
} else out[k] = withImages(v);
|
|
212
|
+
}
|
|
213
|
+
if (placed) for (const k of Object.keys(out)) if (MIME_FIELD.test(k) && typeof out[k] === "string") out[k] = "image/png";
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Why a request got no answer, from the error's name and code alone: its message can quote a header.
|
|
218
|
+
function failedSaid(err, port) {
|
|
219
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError") return `no answer within ${REACH_MS / 1000} seconds.`;
|
|
220
|
+
const code = err?.cause?.code ?? err?.code;
|
|
221
|
+
if (code === "ECONNREFUSED") return `nothing answers on port ${port}.`;
|
|
222
|
+
return `the connection failed${/^[A-Z_]{2,40}$/.test(code ?? "") ? ` (${code})` : ""}.`;
|
|
223
|
+
}
|
package/lib/read-text.mjs
CHANGED
|
@@ -5,72 +5,119 @@ import { at, clip, has, num, pack, pageOf, plural, SIDE } from "./words.mjs";
|
|
|
5
5
|
//
|
|
6
6
|
// The full list comes back from GET /mcp/status?show=<name>: read.rules.all, read.machine.misses
|
|
7
7
|
// complete with read.machine.mets, read.journeys.all, read.doors.all, read.trials.all. Where the API
|
|
8
|
-
// has not sent it, what it did send is listed with "n of N listed".
|
|
9
|
-
|
|
8
|
+
// has not sent it, what it did send is listed with "n of N listed". Where Cortad's team reviewed the
|
|
9
|
+
// code by hand, read.machine is only `reviewed` and the lines that review cites: no standard is graded.
|
|
10
|
+
// `records` lists the plain status's own `read.records`: the server sends them with every status.
|
|
11
|
+
// `reviews` are the two reviews the read leaves, of the prompts and of the code around the model:
|
|
12
|
+
// status hands them over once as news, and this lists them again.
|
|
13
|
+
// `machine` is what the connect command does on this machine, from the runner's record alone.
|
|
14
|
+
export const SHOW = ["rules", "standards", "journeys", "endpoints", "trials", "records", "reviews", "machine"];
|
|
10
15
|
|
|
11
16
|
const rulesHead = (rules) => {
|
|
12
17
|
const files = Array.isArray(rules.files) ? rules.files.length : rules.files;
|
|
13
18
|
return `Rules in the code: ${num(rules.count)}${has(files) ? `, in ${plural(files, "file")}` : ""}.`;
|
|
14
19
|
};
|
|
15
20
|
// What the last run found about the rule beside it; a rule it never asked is not a rule that held.
|
|
16
|
-
const measuredOf = (m) => (m === null ? ";
|
|
21
|
+
const measuredOf = (m) => (m === null ? "; asked of no reply yet" : m ? `; asked of ${plural(m.asked, "reply", "replies")}, passed in ${num(m.held)}${m.asked > m.held ? `, broke in ${num(m.asked - m.held)}` : ""}` : "");
|
|
17
22
|
const ruleLine = (e) => ` ${e.path ? `${at(e.path, e.line)} ` : ""}"${clip(e.text, 300)}"${"measured" in e ? measuredOf(e.measured) : ""}`;
|
|
18
23
|
const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
|
|
19
|
-
|
|
24
|
+
// "28 decided, 28 met, 0 missed, from 0 problems" was four counts for one fact.
|
|
25
|
+
// A review that decided nothing (it failed, or no standard applied) makes no claim: "meets all 0".
|
|
26
|
+
const standardsHead = (m) => (!has(m.met) || !(m.met + (m.missed ?? 0)) ? "Engineering standards:"
|
|
27
|
+
: m.missed ? `Engineering standards: your code misses ${num(m.missed)} of the ${num(m.decided ?? m.met + m.missed)} Cortad checks${has(m.problems) ? `, through ${plural(m.problems, "problem")}` : ""}.`
|
|
28
|
+
: `Engineering standards: your code meets all ${num(m.met)} that Cortad checks.`);
|
|
29
|
+
const REVIEWED_CODE = "The Cortad team reviewed the code around your model by hand instead of grading it against these standards, and status with show reviews lists what it found.";
|
|
20
30
|
const decided = (x) => (x.decidedBy ? ` (decided by ${x.decidedBy === "code" ? "code" : "a model"})` : "");
|
|
21
31
|
// A miss is one problem with the standards it fails beneath it; an older API sent one standard a row.
|
|
32
|
+
// A problem their own documents waive says so, with the line; the server lists it last.
|
|
33
|
+
const designed = (x) => (x.byDesign ? `\n By design here, per ${x.byDesign.at}: "${clip(x.byDesign.said, 200)}"` : "");
|
|
22
34
|
const standardLine = (x) => Array.isArray(x.standards)
|
|
23
|
-
? ` ${x.path ? `${at(x.path, x.line)} ` : ""}${clip(x.problem, 240)}${decided(x)}\n ${x.standards.length === 1 ? "Fails" : `Fails ${num(x.standards.length)}`}: ${x.standards.join("; ")}`
|
|
35
|
+
? ` ${x.path ? `${at(x.path, x.line)} ` : ""}${clip(x.problem, 240)}${decided(x)}${x.fix ? `\n ${clip(x.fix, 300)}` : ""}\n ${x.standards.length === 1 ? "Fails" : `Fails ${num(x.standards.length)}`}: ${x.standards.join("; ")}${designed(x)}`
|
|
24
36
|
: ` ${x.path ? `${at(x.path, x.line)} ` : ""}${x.standard}${x.detail ? `: ${clip(x.detail, 240)}` : ""}${decided(x)}`;
|
|
25
37
|
// Which trials can be played is decided when a run knocks: a read that said "21 playable, none held
|
|
26
38
|
// back" was followed by a run that held 28 back, and three agents called the counts contradictory.
|
|
27
|
-
const trialsHead = (t) => headOf("
|
|
39
|
+
const trialsHead = (t) => headOf("Conversations", [has(t.written) && `${num(t.written)} written`]);
|
|
28
40
|
const heldWhy = (h) => `${SIDE[h.side] ? `, ${SIDE[h.side]}` : ""}${h.why ? `: ${clip(h.why, 300)}` : ""}`;
|
|
29
41
|
const named = (label, group, max = 12) => {
|
|
30
42
|
if (!group) return null;
|
|
31
43
|
const names = group.names ?? [];
|
|
32
44
|
const more = (group.count ?? names.length) - Math.min(names.length, max);
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
// A name may hold commas ("A student, Grade 10 Sem 2 (Jordan), English"): one name ends at a semicolon.
|
|
46
|
+
const list = names.slice(0, max).join("; ");
|
|
47
|
+
return `${label} (${num(group.count ?? names.length)})${list ? `: ${list}${more > 0 ? `; and ${more} more` : ""}` : ""}.`;
|
|
35
48
|
};
|
|
36
49
|
// "Journeys (6):" when every one is here, "Journeys (6), 4 listed:" when not.
|
|
37
50
|
const counted = (label, count, n) => `${label} (${num(count ?? n)})${has(count) && n < count ? `, ${num(n)} listed` : ""}:`;
|
|
38
51
|
|
|
39
52
|
// The records the app already holds, read from its own data files, that a trial may name: an agent
|
|
40
53
|
// that sees a trial ask about SO20260810001 can tell it is a real order, not an invented one.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return `Records the trials can name: ${collections.map((c) => `${c.name} (${c.examples.slice(0, 3).map((e) => clip(e, 40)).join(", ")})`).join(", ")}.`;
|
|
45
|
-
};
|
|
54
|
+
// Listed with show records, never in the walkthrough: ulaim's was 45 ids in one line.
|
|
55
|
+
const recordLines = (records) => (records?.collections ?? []).filter((c) => c?.name && c.examples?.length)
|
|
56
|
+
.map((c) => ` ${c.name}: ${c.examples.slice(0, 3).map((e) => clip(e, 40)).join(", ")}`);
|
|
46
57
|
|
|
47
58
|
// The walkthrough, in the order the person is walked through it.
|
|
59
|
+
// The endpoints are listed above it, proven or not reached, and the questions are the rules and
|
|
60
|
+
// standards counted again: neither is said here, and status with show lists every section in full.
|
|
48
61
|
export function readLines(r) {
|
|
49
62
|
const lines = ["What Cortad read:"];
|
|
50
63
|
if (r.rules) {
|
|
51
64
|
const examples = (r.rules.examples ?? []).slice(0, 3);
|
|
52
|
-
|
|
65
|
+
// "Asked of no reply yet" on each rule before any run is a fact about no run.
|
|
66
|
+
const ran = has(r.rules.measured);
|
|
67
|
+
lines.push(`${rulesHead(r.rules)}${examples.length ? ` ${examples.length} of them:` : ""}`, ...examples.map((e) => ruleLine(ran ? e : { text: e.text, path: e.path, line: e.line })));
|
|
53
68
|
}
|
|
54
|
-
for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles)
|
|
69
|
+
for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles)]) if (line) lines.push(line);
|
|
55
70
|
const m = r.machine;
|
|
56
|
-
if (m) {
|
|
71
|
+
if (m && (m.met || m.missed || (m.misses ?? []).length)) {
|
|
57
72
|
const misses = m.misses ?? [];
|
|
58
73
|
// "3 missed" was read off a list of three under a head that said thirteen: the list says it is a part.
|
|
59
74
|
lines.push(standardsHead(m), ...(misses.length > 3 && has(m.missed) && m.missed > 3 ? [` 3 of the ${num(m.missed)} missed:`] : []), ...misses.slice(0, 3).map(standardLine));
|
|
60
75
|
const more = (m.problems ?? misses.length) - Math.min(misses.length, 3);
|
|
61
76
|
if (more > 0) lines.push(` and ${plural(more, "more problem")}; status with show standards lists them all.`);
|
|
62
77
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
78
|
+
if (r.reviews?.lines.length) lines.push("The reviews of your prompts and of the code around your model are listed whole by status with show reviews.");
|
|
79
|
+
// What a run plays is the quote's to say; here only conversations set aside, and why.
|
|
80
|
+
const held = r.trials?.held ?? [];
|
|
81
|
+
if (held.length) lines.push("Conversations set aside:", ...held.map((h) => ` ${h.surface}: ${plural(h.n, "conversation")}${heldWhy(h)}`));
|
|
82
|
+
return lines.length > 1 ? lines : [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Where each coding agent keeps the entry the connect command writes for Cortad, in the home folder.
|
|
86
|
+
const listed = (names) => (names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0]);
|
|
87
|
+
|
|
88
|
+
// What the connect command does on this machine, from the facts it keeps in the runner's record:
|
|
89
|
+
// what it started and stops, the database copies, the settings its app runs with, what it loads,
|
|
90
|
+
// what a run holds and puts back, and what it keeps and writes outside this repository. At most
|
|
91
|
+
// eight lines, the two for the person first.
|
|
92
|
+
export function machineText(f) {
|
|
93
|
+
if (!f) return ["No connect command is running for this repository now, so nothing Cortad started is running here."];
|
|
94
|
+
const lines = [
|
|
95
|
+
f.started
|
|
96
|
+
? `For the person: Cortad started your app in ${f.dir === "." ? "this folder" : f.dir} and stops it, with everything it started for it, when you press Ctrl-C in its terminal; your code and settings files are never edited.`
|
|
97
|
+
: `For the person: Cortad uses your app already running on port ${f.port} and leaves it running when you press Ctrl-C in its terminal; your code and settings files are never edited.`,
|
|
98
|
+
f.copies === "made"
|
|
99
|
+
? "For the person: Where Cortad could copy a database, your app runs on the copy, made from your own at every connect and deleted when this command ends, so records created in a copy are gone at the next connect; keep records every run needs in your own database. Each Data line in status says what happens to one store."
|
|
100
|
+
: "For the person: No copy of a database was made for this session, so what test conversations create stays where your app keeps its data; each Data line in status names one store.",
|
|
101
|
+
];
|
|
102
|
+
// Env values reach only a process the command started itself.
|
|
103
|
+
if (f.started) lines.push(f.changed?.length ? `Your env file values go into your app's process alone, with ${listed(f.changed)} set for this session only; no value is sent to Cortad.` : "Your env file values go into your app's process alone, unchanged; no value is sent to Cortad.");
|
|
104
|
+
lines.push(f.hooked ? "A hook loaded into your app records each request that reaches a model and the model calls it makes; request headers stay on this machine and are deleted when the command stops."
|
|
105
|
+
: f.proxy ? `A local proxy on port ${f.proxy} sees each request sent to it and the model calls your app makes through it; request headers stay on this machine and are deleted when the command stops.`
|
|
106
|
+
: "Nothing is loaded into your app, since Cortad did not start it, so its model calls are not seen.");
|
|
107
|
+
// Only the hook holds a write or records a file: through the proxy, or with nothing loaded, both go as the app does them.
|
|
108
|
+
lines.push(f.hooked ? "During a run, a write your app sends to a service off this machine, such as a payment or an email, is answered inside your app and never sent, unless it carries a test key, goes to a provider's sandbox, or goes to a host you said yes to on the card; a call named as a read where every call is a POST, such as customers.get, goes out."
|
|
109
|
+
: "During a run, a write your app sends to a service off this machine, such as a payment or an email, is sent for real.");
|
|
110
|
+
lines.push(f.hooked ? "Files your app writes in its own folder during a run are put back when run_status reads the run's end; its log files are left as they are." : "Files your app writes in its own folder stay as it writes them.");
|
|
111
|
+
lines.push("Kept between connects in ~/.cortad on this machine: this repository's key for later runs, the key the test accounts' passwords are made from, the files to put back, a list of what to undo, and your app's output from the last session with the values from your env files masked, which the next connect starts over.");
|
|
67
112
|
return lines;
|
|
68
113
|
}
|
|
69
114
|
|
|
70
115
|
export function showText(d, show, page = 1) {
|
|
71
|
-
if (!d.read) return "Cortad is still reading this repository.";
|
|
116
|
+
if (!d.read || (d.read.complete === false && show !== "reviews")) return "Cortad is still reading this repository.";
|
|
72
117
|
const { head, lines, continued } = sectionOf(d.read, show);
|
|
73
|
-
|
|
118
|
+
// A heading with nothing under it ("Endpoints (0):") reads as a list that failed to print.
|
|
119
|
+
const said = lines.length || !head.endsWith(":") ? head : `${head.replace(/ \(0\):$|:$/, "")}: none yet.`;
|
|
120
|
+
return pageOf(pack(said, lines, continued), page, (n) => `status with show ${show} and page ${n}`);
|
|
74
121
|
}
|
|
75
122
|
|
|
76
123
|
function sectionOf(r, show) {
|
|
@@ -82,6 +129,7 @@ function sectionOf(r, show) {
|
|
|
82
129
|
}
|
|
83
130
|
case "standards": {
|
|
84
131
|
const m = r.machine ?? {};
|
|
132
|
+
if (m.reviewed) return { head: REVIEWED_CODE, lines: [], continued: "" };
|
|
85
133
|
const misses = m.misses ?? [];
|
|
86
134
|
const mets = m.mets ?? [];
|
|
87
135
|
return {
|
|
@@ -98,12 +146,18 @@ function sectionOf(r, show) {
|
|
|
98
146
|
const all = r.doors?.all ?? (r.doors?.names ?? []).map((name) => ({ name }));
|
|
99
147
|
return { head: counted("Endpoints", r.doors?.count, all.length), lines: all.map((e) => ` ${e.name}${e.path ? `: ${[e.method, e.path].filter(Boolean).join(" ")}` : ""}`), continued: "Endpoints, continued." };
|
|
100
148
|
}
|
|
149
|
+
case "records":
|
|
150
|
+
return recordLines(r.records).length
|
|
151
|
+
? { head: "Records a conversation can name, three of each:", lines: recordLines(r.records), continued: "Records, continued." }
|
|
152
|
+
: { head: "Reading your code found no records a conversation can name.", lines: [], continued: "" };
|
|
153
|
+
case "reviews":
|
|
154
|
+
return { head: "The reviews of your prompts and of the code around your model:", lines: r.reviews?.lines.length ? r.reviews.lines : [r.complete === false ? "Your code is still being read; the reviews come when that is done." : r.reviews ? "Reading your code left no review of this app's prompts or code." : "This status carries no reviews."], continued: "The reviews, continued." };
|
|
101
155
|
case "trials":
|
|
102
156
|
default: {
|
|
103
157
|
const t = r.trials ?? {};
|
|
104
158
|
const all = t.all ?? (t.held ?? []).map((h) => ({ surface: h.surface, held: h.n, why: h.why, side: h.side }));
|
|
105
|
-
const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)}
|
|
106
|
-
return { head: trialsHead(t), lines: all.map(line), continued: "
|
|
159
|
+
const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)} set aside`].filter(Boolean).join(", ")}${x.held ? heldWhy(x) : ""}`;
|
|
160
|
+
return { head: trialsHead(t), lines: all.map(line), continued: "Conversations, continued." };
|
|
107
161
|
}
|
|
108
162
|
}
|
|
109
163
|
}
|
package/lib/redis.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A Redis database copied for a session into an empty logical database of the same server, and
|
|
2
|
+
// emptied after. Spoken over the wire here (RESP), so nothing needs redis-cli on this machine and
|
|
3
|
+
// the password never sits on a command line.
|
|
4
|
+
import { connect as tcp } from "node:net";
|
|
5
|
+
import { connect as tls } from "node:tls";
|
|
6
|
+
|
|
7
|
+
// A database with more keys than this is not copied: said instead.
|
|
8
|
+
export const REDIS_CAP = 200_000;
|
|
9
|
+
// Written into the copy, so an emptying after a crash only ever empties a database this command
|
|
10
|
+
// filled: a database index can be taken by something else between two sessions.
|
|
11
|
+
export const MARKER = "__cortad_copy__";
|
|
12
|
+
const BATCH = 500;
|
|
13
|
+
|
|
14
|
+
// One connection, commands sent in batches and answered in order.
|
|
15
|
+
async function open({ host, port, tlsOn, user, password }) {
|
|
16
|
+
const socket = await new Promise((ok, no) => {
|
|
17
|
+
const s = (tlsOn ? tls : tcp)({ host: host || "127.0.0.1", port, ...(tlsOn ? { servername: host } : {}) });
|
|
18
|
+
s.setTimeout(10_000, () => s.destroy(new Error("timeout")));
|
|
19
|
+
s.once(tlsOn ? "secureConnect" : "connect", () => ok(s));
|
|
20
|
+
s.once("error", no);
|
|
21
|
+
});
|
|
22
|
+
let buf = Buffer.alloc(0);
|
|
23
|
+
const waiting = [];
|
|
24
|
+
const replies = [];
|
|
25
|
+
socket.on("data", (chunk) => {
|
|
26
|
+
buf = Buffer.concat([buf, chunk]);
|
|
27
|
+
for (let r; (r = parse(buf, 0)); ) { buf = buf.subarray(r.end); replies.push(r.value); }
|
|
28
|
+
while (waiting.length && replies.length >= waiting[0].n) { const w = waiting.shift(); w.ok(replies.splice(0, w.n)); }
|
|
29
|
+
});
|
|
30
|
+
socket.on("error", (e) => { for (const w of waiting.splice(0)) w.no(e); });
|
|
31
|
+
const send = (cmds) => new Promise((ok, no) => {
|
|
32
|
+
waiting.push({ n: cmds.length, ok, no });
|
|
33
|
+
socket.write(Buffer.concat(cmds.map(encode)));
|
|
34
|
+
});
|
|
35
|
+
const client = { send, one: async (...cmd) => (await send([cmd]))[0], close: () => socket.end() };
|
|
36
|
+
if (password) {
|
|
37
|
+
const said = await client.one(...(user ? ["AUTH", user, password] : ["AUTH", password]));
|
|
38
|
+
// A development server with no password set refuses AUTH itself; the app connects the same way.
|
|
39
|
+
if (said instanceof Error && !/without any password configured|no password is set/i.test(said.message)) { client.close(); throw Object.assign(new Error("auth"), { why: "auth" }); }
|
|
40
|
+
}
|
|
41
|
+
return client;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Arguments go as bytes: a key is whatever bytes the app named it with, and it is copied as such.
|
|
45
|
+
const encode = (cmd) => Buffer.concat([Buffer.from(`*${cmd.length}\r\n`), ...cmd.flatMap((a) => {
|
|
46
|
+
const b = Buffer.isBuffer(a) ? a : Buffer.from(String(a));
|
|
47
|
+
return [Buffer.from(`$${b.length}\r\n`), b, Buffer.from("\r\n")];
|
|
48
|
+
})]);
|
|
49
|
+
|
|
50
|
+
// One reply from buf at i, or null until the whole of it has arrived.
|
|
51
|
+
function parse(buf, i) {
|
|
52
|
+
const eol = buf.indexOf("\r\n", i);
|
|
53
|
+
if (eol < 0) return null;
|
|
54
|
+
const type = String.fromCharCode(buf[i]);
|
|
55
|
+
const line = buf.subarray(i + 1, eol).toString();
|
|
56
|
+
if (type === "+") return { value: line, end: eol + 2 };
|
|
57
|
+
if (type === "-") return { value: new Error(line), end: eol + 2 };
|
|
58
|
+
if (type === ":") return { value: Number(line), end: eol + 2 };
|
|
59
|
+
if (type === "$") {
|
|
60
|
+
const n = Number(line);
|
|
61
|
+
if (n < 0) return { value: null, end: eol + 2 };
|
|
62
|
+
if (buf.length < eol + 2 + n + 2) return null;
|
|
63
|
+
return { value: Buffer.from(buf.subarray(eol + 2, eol + 2 + n)), end: eol + 2 + n + 2 };
|
|
64
|
+
}
|
|
65
|
+
if (type === "*") {
|
|
66
|
+
const n = Number(line);
|
|
67
|
+
const items = [];
|
|
68
|
+
let at = eol + 2;
|
|
69
|
+
for (let k = 0; k < n; k++) { const r = parse(buf, at); if (!r) return null; items.push(r.value); at = r.end; }
|
|
70
|
+
return { value: n < 0 ? null : items, end: at };
|
|
71
|
+
}
|
|
72
|
+
return { value: new Error(`unreadable reply ${type}`), end: eol + 2 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const why = (e) => e?.why ?? (/NOAUTH|WRONGPASS|invalid password/i.test(String(e?.message)) ? "auth" : /ECONNREFUSED|ENOTFOUND|timeout|EHOSTUNREACH/i.test(String(e?.code ?? e?.message)) ? "down" : "other");
|
|
76
|
+
|
|
77
|
+
// COPY came in Redis 6.2. A server that hides INFO is let try: a refused COPY is said then.
|
|
78
|
+
export function tooOld(info) {
|
|
79
|
+
const m = /redis_version:(\d+)\.(\d+)/.exec(Buffer.isBuffer(info) ? info.toString() : "");
|
|
80
|
+
return Boolean(m) && (Number(m[1]) < 6 || (Number(m[1]) === 6 && Number(m[2]) < 2));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The copy: the first empty database from the top of the server's range, claimed with the marker,
|
|
84
|
+
// then every key of the source copied into it with COPY. Answers { index } or { why }.
|
|
85
|
+
export async function cloneRedis(conn, source, marker) {
|
|
86
|
+
let c;
|
|
87
|
+
try { c = await open(conn); } catch (e) { return { why: why(e) }; }
|
|
88
|
+
try {
|
|
89
|
+
const [picked, size, info] = await c.send([["SELECT", source], ["DBSIZE"], ["INFO", "server"]]);
|
|
90
|
+
if (picked instanceof Error) return { why: /cluster/i.test(picked.message) ? "cluster" : why(picked) };
|
|
91
|
+
if (tooOld(info)) return { why: "oldServer" };
|
|
92
|
+
if (size > REDIS_CAP) return { why: "big" };
|
|
93
|
+
let index = null;
|
|
94
|
+
for (let n = 15; n >= 1 && index === null; n--) {
|
|
95
|
+
if (n === source) continue;
|
|
96
|
+
const [sel, count] = await c.send([["SELECT", n], ["DBSIZE"]]);
|
|
97
|
+
if (sel instanceof Error) continue;
|
|
98
|
+
if (count === 0) index = n;
|
|
99
|
+
}
|
|
100
|
+
if (index === null) return { why: "full" };
|
|
101
|
+
await c.send([["SELECT", index], ["SET", MARKER, marker], ["SELECT", source]]);
|
|
102
|
+
let cursor = "0";
|
|
103
|
+
do {
|
|
104
|
+
const [next, keys] = await c.one("SCAN", cursor, "COUNT", BATCH);
|
|
105
|
+
cursor = String(next);
|
|
106
|
+
const copied = keys.length ? await c.send(keys.map((k) => ["COPY", k, k, "DB", index])) : [];
|
|
107
|
+
const refused = copied.find((r) => r instanceof Error);
|
|
108
|
+
if (refused) { await c.send([["SELECT", index], ["FLUSHDB"]]); return { why: "other" }; }
|
|
109
|
+
} while (cursor !== "0");
|
|
110
|
+
return { index };
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return { why: why(e) };
|
|
113
|
+
} finally {
|
|
114
|
+
c.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Emptied only when the marker in it is this session's. With no index, the database is found by its
|
|
119
|
+
// marker: a session killed mid-copy recorded the copy before it knew which database it claimed.
|
|
120
|
+
export async function dropRedis(conn, index, marker) {
|
|
121
|
+
let c;
|
|
122
|
+
try { c = await open(conn); } catch { return false; }
|
|
123
|
+
try {
|
|
124
|
+
for (const n of index == null ? Array.from({ length: 15 }, (_, i) => 15 - i) : [index]) {
|
|
125
|
+
const [sel, held] = await c.send([["SELECT", n], ["GET", MARKER]]);
|
|
126
|
+
if (!(sel instanceof Error) && String(held) === marker) return (await c.one("FLUSHDB")) === "OK";
|
|
127
|
+
}
|
|
128
|
+
return index == null;
|
|
129
|
+
} catch { return false; } finally { c.close(); }
|
|
130
|
+
}
|