cortad 0.3.8 → 0.3.9
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/lib/auto-reach.mjs +54 -8
- package/lib/calls.mjs +25 -13
- package/lib/contact-text.mjs +10 -4
- package/lib/keeping.mjs +8 -3
- package/lib/mcp.mjs +9 -6
- package/lib/pyhook/sitecustomize.py +219 -5
- package/lib/qdrant.mjs +3 -2
- package/lib/reach.mjs +13 -6
- package/lib/replay.mjs +6 -1
- package/lib/spec.mjs +12 -2
- package/lib/stores.mjs +29 -2
- package/lib/text.mjs +17 -2
- package/lib/trace.cjs +118 -15
- package/lib/verbs.mjs +7 -4
- package/lib/wire.cjs +25 -1
- package/local.mjs +11 -5
- package/package.json +1 -1
- package/skill/SKILL.md +10 -9
- package/skill/references/results.md +1 -1
package/lib/auto-reach.mjs
CHANGED
|
@@ -19,15 +19,53 @@ const keyOf = (m) => `${m.method} ${m.path}`;
|
|
|
19
19
|
// `asked()`: true once the agent's ask waits, so a round stops between two sends to take it.
|
|
20
20
|
// `report(answers)`: what each test request met, for the server (POST /local/:box/reached). Each round
|
|
21
21
|
// opens with an empty list, which says this command sends them, so status stops asking the agent to;
|
|
22
|
-
// the caller posts it once per connection.
|
|
23
|
-
|
|
22
|
+
// the caller posts it once per connection. Each answer is reported as it lands: reported only when
|
|
23
|
+
// its round ended, one endpoint that took 55 seconds held every faster one's answer back with it.
|
|
24
|
+
// `report` answering false, or failing, leaves its answers kept here and said again later: the server
|
|
25
|
+
// hears them late, and the app is never asked again for the sake of a report that did not arrive.
|
|
26
|
+
// What is kept is each endpoint's latest answer, so it is never more than the endpoints the app has
|
|
27
|
+
// and nothing is dropped to make room. One report is out at a time; the first that fails stops the
|
|
28
|
+
// rest, and the next is tried RETRY_MS later, twice as long after each failure up to RETRY_MAX_MS:
|
|
29
|
+
// 250 answers behind a server that was not answering were 250 reports of 15 seconds each, one after
|
|
30
|
+
// the other, and the fifty oldest were thrown away with their endpoints already marked as tested.
|
|
31
|
+
// `roundMs`: past it a round starts no further request; the rows left are sent in the next round. It
|
|
32
|
+
// bounds what a round starts, not a request already out (REACH_MS each) nor the connection as a whole.
|
|
33
|
+
export const ROUND_MS = 90_000;
|
|
34
|
+
const RETRY_MS = 5_000;
|
|
35
|
+
const RETRY_MAX_MS = 60_000;
|
|
36
|
+
const BATCH = 100;
|
|
37
|
+
export function makeAutoReach({ status, runner, waiting, origin, signIn, stampOf, asked = () => false, report = async () => {}, say, fetchImpl = fetch, roundMs = ROUND_MS, now = Date.now }) {
|
|
24
38
|
const sent = new Map();
|
|
25
39
|
let n = 0;
|
|
26
40
|
const next = () => ++n;
|
|
41
|
+
const kept = new Map();
|
|
42
|
+
let draining = null, retryAt = 0, backoff = 0;
|
|
43
|
+
const drain = async () => {
|
|
44
|
+
while (kept.size && now() >= retryAt) {
|
|
45
|
+
const batch = [...kept.values()].slice(0, BATCH);
|
|
46
|
+
const ok = await Promise.resolve().then(() => report(batch)).then((r) => r !== false, () => false);
|
|
47
|
+
if (!ok) {
|
|
48
|
+
// Said once per outage, so a card that is not moving has its reason in the terminal.
|
|
49
|
+
if (!backoff) say(`Cortad did not take what ${kept.size === 1 ? "1 test request" : `${kept.size} test requests`} met. They are kept here and sent again; your app is not asked again.`);
|
|
50
|
+
backoff = Math.min(backoff ? backoff * 2 : RETRY_MS, RETRY_MAX_MS);
|
|
51
|
+
retryAt = now() + backoff;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
backoff = 0;
|
|
55
|
+
// An endpoint answered again while its last answer was out keeps the newer one.
|
|
56
|
+
for (const a of batch) if (kept.get(a.door) === a) kept.delete(a.door);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const tell = (met = []) => {
|
|
60
|
+
for (const a of met) kept.set(a.door, a);
|
|
61
|
+
draining ??= drain().finally(() => { draining = null; });
|
|
62
|
+
return draining;
|
|
63
|
+
};
|
|
27
64
|
// `ask`: the agent's call, { paths }: sent whatever was sent before, and answered with each line.
|
|
28
65
|
// `wait`: when to look again: soon while the code is still being read, rarely once it is.
|
|
29
66
|
return async function round(ask = null) {
|
|
30
|
-
await report([]).catch(() => {});
|
|
67
|
+
await Promise.resolve().then(() => report([])).catch(() => {});
|
|
68
|
+
await tell();
|
|
31
69
|
const here = runner();
|
|
32
70
|
const down = notUp(here);
|
|
33
71
|
if (down) return { text: `${down} Nothing was sent.\nnext: status`, wait: 5_000 };
|
|
@@ -37,14 +75,22 @@ export function makeAutoReach({ status, runner, waiting, origin, signIn, stampOf
|
|
|
37
75
|
if (!d) return { text: "Cortad did not answer with the list of endpoints. Nothing was sent.\nnext: status", wait: 30_000 };
|
|
38
76
|
const pick = picked(d.contact, here.receipts, ask?.paths);
|
|
39
77
|
const due = pick.sent.filter((m) => !m.excluded && (ask || sent.get(keyOf(m)) !== stampOf(m)));
|
|
40
|
-
const
|
|
41
|
-
|
|
78
|
+
const until = now() + roundMs;
|
|
79
|
+
let cut = false;
|
|
80
|
+
const answers = due.length ? await sendOurs(due, {
|
|
81
|
+
runner: here, origin: origin(), signIn, next, stop: () => (!ask && asked()) || (cut = now() >= until), fetchImpl,
|
|
82
|
+
onAnswer: (a, i) => { const met = outcomes([due[i]], [a]); if (met.length) void tell(met); },
|
|
83
|
+
}) : [];
|
|
84
|
+
await tell();
|
|
85
|
+
// A request that never reached the app (refused, or a GET cut off) is sent again next round. One
|
|
86
|
+
// that may have reached it, a POST reset or timed out, is not: sending it twice could do twice
|
|
87
|
+
// whatever it does. The agent's `reach` or an edit to its file sends it again.
|
|
42
88
|
answers.forEach((a, i) => { if (!a.down) sent.set(keyOf(due[i]), stampOf(due[i])); });
|
|
43
|
-
const met = outcomes(due, answers);
|
|
44
|
-
for (let i = 0; i < met.length; i += 100) await report(met.slice(i, i + 100)).catch(() => {});
|
|
45
89
|
const line = testedLine(answers);
|
|
46
90
|
if (line) say(line);
|
|
47
|
-
|
|
91
|
+
// Rows the round's time left unsent are due in seconds, never a minute on. An ask of the agent's
|
|
92
|
+
// that stopped the round is taken within a second whatever this says.
|
|
93
|
+
return { text: reachText({ ...pick, sent: due.slice(0, answers.length) }, answers, `${here.port}, up to four at a time`), wait: !cut && !kept.size && d.read?.complete === true ? 60_000 : 5_000 };
|
|
48
94
|
};
|
|
49
95
|
}
|
|
50
96
|
|
package/lib/calls.mjs
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
// What Cortad counts about its own tools: which one an agent called, from which client, how long it
|
|
2
2
|
// took, how much text came back and, when it failed, under which code. The arguments and the answer
|
|
3
|
-
// stay on this machine. Sent with the machine's own key
|
|
4
|
-
//
|
|
3
|
+
// stay on this machine. Sent with the machine's own key. A count that is lost is not sent again: it
|
|
4
|
+
// must not cost the tool call it describes. One kind of call is: the agent's own test request
|
|
5
|
+
// (`reach`), which Cortad counts as a try at fixing whatever holds Run. Lost, that try would go
|
|
6
|
+
// uncounted, so its report is sent again, a few times and no more, always under the call's own id
|
|
7
|
+
// (`callId`, made when the call arrived): the server takes an id once however often it arrives.
|
|
5
8
|
import { randomUUID } from "node:crypto";
|
|
6
9
|
|
|
7
10
|
const text = (v, max) => (typeof v === "string" && v ? v.slice(0, max) : undefined);
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const RETRIES = 3;
|
|
13
|
+
const RETRY_MS = 400;
|
|
14
|
+
|
|
15
|
+
export function makeReport({ api, token, version, fetchImpl = fetch, sessionId = randomUUID(), wait = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
16
|
+
return async ({ tool, callId, durationMs, outputChars, errorCode, error, clientName, clientVersion }) => {
|
|
11
17
|
const key = token();
|
|
12
18
|
if (!key) return;
|
|
13
|
-
const call = { tool, durationMs, outputChars, errorCode, error: text(error?.split("\n")[0], 512), clientName: text(clientName, 80), clientVersion: text(clientVersion, 40), serverVersion: version, sessionId };
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const call = { tool, ...(callId ? { callId } : {}), durationMs, outputChars, errorCode, error: text(error?.split("\n")[0], 512), clientName: text(clientName, 80), clientVersion: text(clientVersion, 40), serverVersion: version, sessionId };
|
|
20
|
+
const tries = tool === "reach" && callId ? RETRIES : 1;
|
|
21
|
+
for (let n = 1; n <= tries; n++) {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetchImpl(`${api}/mcp/calls`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify([call]),
|
|
27
|
+
signal: AbortSignal.timeout(3000),
|
|
28
|
+
});
|
|
29
|
+
// Taken, or refused for good: either way it is not sent again.
|
|
30
|
+
if (!res || res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) return;
|
|
31
|
+
} catch { /* lost */ }
|
|
32
|
+
if (n < tries) await wait(n * RETRY_MS);
|
|
33
|
+
}
|
|
22
34
|
};
|
|
23
35
|
}
|
package/lib/contact-text.mjs
CHANGED
|
@@ -130,9 +130,11 @@ function readyLine(r) {
|
|
|
130
130
|
|
|
131
131
|
// The endpoints no request has reached, as status lists them and reach sends them. `receipts`: doors
|
|
132
132
|
// the App section says answered at once with their answer out of Cortad's sight, so not said here.
|
|
133
|
-
|
|
133
|
+
// `retests`: the doors whose test failed, listed again to be sent once more: reach takes them, the
|
|
134
|
+
// list of endpoints no request reached does not.
|
|
135
|
+
export const unreached = (c, receipts = [], { retests = false } = {}) => {
|
|
134
136
|
const answered = new Set(receipts.map((x) => x.door));
|
|
135
|
-
return (c?.notCalled ?? []).filter((m) => !answered.has(`${m.method} ${m.path}`));
|
|
137
|
+
return (c?.notCalled ?? []).filter((m) => !answered.has(`${m.method} ${m.path}`) && (retests || !m.retest));
|
|
136
138
|
};
|
|
137
139
|
|
|
138
140
|
// One the server has no request for, or one listed on a field's name alone, is the agent's to send.
|
|
@@ -155,8 +157,12 @@ export function contactLines(c, data = [], receipts = []) {
|
|
|
155
157
|
const said = data.map((s) => `Data: ${s}`);
|
|
156
158
|
if (!c) return said;
|
|
157
159
|
const proven = c.proven ?? [];
|
|
158
|
-
// No request yet is said by the App line and by the next step, not a third time here.
|
|
159
|
-
|
|
160
|
+
// No request yet is said by the App line and by the next step, not a third time here. Endpoints
|
|
161
|
+
// that all answered only Cortad's own test request say so once, over the list: sixteen of them
|
|
162
|
+
// each said it under their own line, and the agent asked for a status it could read.
|
|
163
|
+
const alike = proven.length > 1 && proven.every((v) => v.standing === "fallback") && new Set(proven.map((v) => v.why)).size === 1;
|
|
164
|
+
const head = alike ? `Endpoints that answered (${num(proven.length)}), each only Cortad's own test request so far: ${proven[0].why}` : `Endpoints that answered (${num(proven.length)}):`;
|
|
165
|
+
const lines = proven.length ? [head, ...proven.flatMap((v) => doorLines(alike ? { ...v, standing: null } : v))] : [];
|
|
160
166
|
const missing = unreached(c, receipts);
|
|
161
167
|
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));
|
|
162
168
|
const ready = readyLine(c.ready);
|
package/lib/keeping.mjs
CHANGED
|
@@ -38,7 +38,7 @@ function blockedRows(blocked) {
|
|
|
38
38
|
// that command gives the app: a legal research API typed ENV=staging and was started on copies of
|
|
39
39
|
// the test databases its default settings file names, where its library is empty.
|
|
40
40
|
const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
|
|
41
|
-
export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {}, typed = false }) {
|
|
41
|
+
export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, copies = () => [], hold = () => {}, typed = false }) {
|
|
42
42
|
let pending = null;
|
|
43
43
|
let now = null;
|
|
44
44
|
let backing = null;
|
|
@@ -50,7 +50,9 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
50
50
|
async function keepAll(started) {
|
|
51
51
|
if (typed) {
|
|
52
52
|
const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
|
|
53
|
-
|
|
53
|
+
// The app's own command may still reach a Qdrant: the copies the hook makes there are dropped too.
|
|
54
|
+
const keeper = storeKeeper({ ledgerFile, values: {}, dirs: [] });
|
|
55
|
+
now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {}, dropCopies: (c, s) => keeper.dropCopies(c, s) }, values: {}, started };
|
|
54
56
|
tell(now.notes);
|
|
55
57
|
return now;
|
|
56
58
|
}
|
|
@@ -157,12 +159,15 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
157
159
|
const k = await pending;
|
|
158
160
|
pending = null;
|
|
159
161
|
now = null;
|
|
162
|
+
await k?.keeper.dropCopies(copies(), k.stores).catch(() => {});
|
|
160
163
|
await k?.keeper.drop();
|
|
161
164
|
},
|
|
162
165
|
// Once the app has stopped: a copy is dropped when nothing holds it open, and a store is stopped
|
|
163
166
|
// only after its copy is gone.
|
|
164
167
|
async close() {
|
|
165
|
-
|
|
168
|
+
const k = await pending?.catch(() => null);
|
|
169
|
+
await k?.keeper.dropCopies(copies(), k.stores).catch(() => {});
|
|
170
|
+
await k?.keeper.drop().catch(() => {});
|
|
166
171
|
await backing?.stop().catch(() => {});
|
|
167
172
|
},
|
|
168
173
|
};
|
package/lib/mcp.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// dependency. stdout carries protocol messages and nothing else; everything a person might read
|
|
3
3
|
// goes to stderr. The tools are lib/verbs.mjs, one to one.
|
|
4
4
|
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { SHOW } from "./read-text.mjs";
|
|
6
7
|
|
|
7
8
|
export const PROTOCOL = "2025-06-18";
|
|
@@ -10,7 +11,7 @@ export const PROTOCOL = "2025-06-18";
|
|
|
10
11
|
// every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
|
|
11
12
|
export const instructions = (spec = "cortad") => [
|
|
12
13
|
"Cortad tests the AI in this repository: simulated users written from the app's code talk to the app running on this machine, and every reply is checked. Cortad sends each AI endpoint a test request itself. status shows the app's port, what the reviews of the prompts and of the code around the model found, which endpoints answered and which failed at what file and line, and what a run would play and cost. The first run starts by itself; later runs when the person presses Run or asks.",
|
|
13
|
-
`Tell the person what status found in a few plain lines. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run ends, with what it found. After a run, call findings and
|
|
14
|
+
`Tell the person what status found in a few plain lines. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run or verify ends, with what it found. After a run, call findings and tell the person what it found and what you would change, worst first; the person then says which fix to make, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations and says whether the failure is gone, and a further round plays only when verify is called again. findings has each failure whole and findings numbers every rate. field_connect gives the steps to connect production. dispute records a check that reads this app wrong.`,
|
|
14
15
|
].join("\n\n");
|
|
15
16
|
export const INSTRUCTIONS = instructions();
|
|
16
17
|
|
|
@@ -69,8 +70,9 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
69
70
|
return new Promise((resolve) => {
|
|
70
71
|
let client = {};
|
|
71
72
|
const sending = new Set();
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
// `callId`: this call's own name, made when it arrives and sent with every report of it (lib/calls.mjs).
|
|
74
|
+
const count = (tool, began, text, failure, callId) => {
|
|
75
|
+
const sent = Promise.resolve(report({ tool, callId, durationMs: Date.now() - began, outputChars: text.length, ...failure, ...client })).catch(() => {}).finally(() => sending.delete(sent));
|
|
74
76
|
sending.add(sent);
|
|
75
77
|
};
|
|
76
78
|
let closed = false;
|
|
@@ -97,6 +99,7 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
97
99
|
const { id, method, params } = msg;
|
|
98
100
|
const notification = id === undefined || id === null;
|
|
99
101
|
const began = Date.now();
|
|
102
|
+
const callId = randomUUID();
|
|
100
103
|
try {
|
|
101
104
|
switch (method) {
|
|
102
105
|
case "initialize":
|
|
@@ -113,11 +116,11 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
113
116
|
const name = params?.name;
|
|
114
117
|
const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
|
|
115
118
|
if (!verb) {
|
|
116
|
-
count("unknown", began, "", { errorCode: "unknown_tool" });
|
|
119
|
+
count("unknown", began, "", { errorCode: "unknown_tool" }, callId);
|
|
117
120
|
return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
|
|
118
121
|
}
|
|
119
122
|
const out = await verb(params?.arguments ?? {});
|
|
120
|
-
count(name, began, out.text, out.isError ? { errorCode: out.code ?? "refused", error: out.why } : {});
|
|
123
|
+
count(name, began, out.text, out.isError ? { errorCode: out.code ?? "refused", error: out.why } : {}, callId);
|
|
121
124
|
const structured = out.structured && typeof out.structured === "object" && !Array.isArray(out.structured) ? { structuredContent: out.structured } : {};
|
|
122
125
|
return send(result(id, { content: [{ type: "text", text: out.text }], ...structured, ...(out.isError ? { isError: true } : {}) }));
|
|
123
126
|
}
|
|
@@ -126,7 +129,7 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
126
129
|
}
|
|
127
130
|
} catch (err) {
|
|
128
131
|
log(`mcp ${method}: ${err?.stack ?? err}`);
|
|
129
|
-
if (method === "tools/call") count(NAMES.has(params?.name) ? params.name : "unknown", began, "", { errorCode: "internal" });
|
|
132
|
+
if (method === "tools/call") count(NAMES.has(params?.name) ? params.name : "unknown", began, "", { errorCode: "internal" }, callId);
|
|
130
133
|
if (!notification) send(result(id, { content: [{ type: "text", text: `Cortad could not answer: ${String(err?.message ?? err)}` }], isError: true }));
|
|
131
134
|
}
|
|
132
135
|
}
|
|
@@ -118,7 +118,7 @@ def _install():
|
|
|
118
118
|
import re
|
|
119
119
|
import sys
|
|
120
120
|
import time
|
|
121
|
-
from urllib.parse import urlsplit
|
|
121
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
122
122
|
|
|
123
123
|
ctx = contextvars.ContextVar("cortad_request", default=None)
|
|
124
124
|
limit = 65536
|
|
@@ -882,6 +882,31 @@ def _install():
|
|
|
882
882
|
return False
|
|
883
883
|
return bool(retrieval_host.search(parts.hostname or "")) or bool(retrieval_path.search(parts.path or ""))
|
|
884
884
|
|
|
885
|
+
# A call to one Qdrant collection, by Qdrant's own REST shape (lib/wire.cjs qdrantCall): every
|
|
886
|
+
# PUT, PATCH and DELETE writes, and a POST does unless it is one of Qdrant's reads spelled as a
|
|
887
|
+
# POST. /collections/aliases names no collection: a write of the server itself.
|
|
888
|
+
qdrant_collection = re.compile(r"^/collections/([^/]+)(/.*)?$")
|
|
889
|
+
qdrant_read_post = re.compile(r"^/points(?:/(?:search|query|recommend|discover)(?:/(?:batch|groups))?|/scroll|/count|/facet|/search/matrix/(?:pairs|offsets))?$")
|
|
890
|
+
qdrant_points = re.compile(r"^/points(?:/(?:delete|payload|payload/delete|payload/clear|vectors|vectors/delete|batch))?$")
|
|
891
|
+
|
|
892
|
+
def qdrant_call(url, method):
|
|
893
|
+
try:
|
|
894
|
+
parts = urlsplit(str(url))
|
|
895
|
+
except ValueError:
|
|
896
|
+
return None
|
|
897
|
+
if parts.scheme not in ("http", "https"):
|
|
898
|
+
return None
|
|
899
|
+
m = qdrant_collection.match(parts.path or "")
|
|
900
|
+
if not m:
|
|
901
|
+
return None
|
|
902
|
+
name, rest = m.group(1), m.group(2) or ""
|
|
903
|
+
verb = str(method or "GET").upper()
|
|
904
|
+
write = verb in ("PUT", "PATCH", "DELETE") or (verb == "POST" and not qdrant_read_post.match(rest))
|
|
905
|
+
# "points" changes what the collection holds and a copy stands in for it; "create" makes it;
|
|
906
|
+
# "schema" (an index, an alias, a snapshot, its settings or its deletion) is held, never copied.
|
|
907
|
+
kind = None if not write else "create" if rest == "" and verb == "PUT" else "points" if qdrant_points.match(rest) and verb != "DELETE" else "schema"
|
|
908
|
+
return {"collection": None if name == "aliases" else unquote(name), "rest": rest, "write": write, "kind": kind, "parts": parts}
|
|
909
|
+
|
|
885
910
|
def passages_from(raw):
|
|
886
911
|
out, nodes = [], [0]
|
|
887
912
|
|
|
@@ -1344,6 +1369,11 @@ def _install():
|
|
|
1344
1369
|
return False
|
|
1345
1370
|
elif run_seen["at"] is None or time.monotonic() - run_seen["at"] > run_live_s:
|
|
1346
1371
|
return False
|
|
1372
|
+
# A read of a Qdrant collection, however its server is named, and any call to a copy this
|
|
1373
|
+
# hook made (copy_of below), go out.
|
|
1374
|
+
q = qdrant_call(url, method)
|
|
1375
|
+
if q and (not q["write"] or (q["collection"] and our_copy.search(q["collection"]))):
|
|
1376
|
+
return False
|
|
1347
1377
|
try:
|
|
1348
1378
|
parts = urlsplit(str(url))
|
|
1349
1379
|
host = (parts.hostname or "").lower()
|
|
@@ -1353,6 +1383,150 @@ def _install():
|
|
|
1353
1383
|
except Exception:
|
|
1354
1384
|
return True
|
|
1355
1385
|
|
|
1386
|
+
# A write to a Qdrant collection made inside a run goes to a copy of that collection on the same
|
|
1387
|
+
# server, made at the first such write: the collection is read page by page and written under
|
|
1388
|
+
# `<name>_cortad_<pid>`, and from then on every call a run makes to that collection, reads too,
|
|
1389
|
+
# goes to the copy, so the app finds what it wrote. The original is never written. A copy that
|
|
1390
|
+
# cannot be made (over the size a run copies, a server that refused) leaves the write held like
|
|
1391
|
+
# any other. Each copy is reported as a `copy` row and the command deletes them when it ends.
|
|
1392
|
+
# The person's own calls, outside a run, go where they were sent (lib/trace.cjs does the same).
|
|
1393
|
+
import threading
|
|
1394
|
+
our_copy = re.compile(r"_cortad_\d+$")
|
|
1395
|
+
copies = {}
|
|
1396
|
+
copies_lock = threading.Lock()
|
|
1397
|
+
qdrant_cap = 200000
|
|
1398
|
+
# What one process copies at most: collections, and points across them; past it a write is held.
|
|
1399
|
+
copies_max = 8
|
|
1400
|
+
points_max = 300000
|
|
1401
|
+
copied = {"collections": 0, "points": 0}
|
|
1402
|
+
copy_said = set()
|
|
1403
|
+
|
|
1404
|
+
def copy_say(host, collection, why):
|
|
1405
|
+
if (host, collection) in copy_said:
|
|
1406
|
+
return
|
|
1407
|
+
copy_said.add((host, collection))
|
|
1408
|
+
try:
|
|
1409
|
+
sys.stderr.write("cortad: a request Cortad sent made this app write into the Qdrant collection %s at %s. Cortad could not copy that collection on its server (%s), so the write was kept on this machine and answered with a stand-in success.\n" % (collection, host, why))
|
|
1410
|
+
except Exception:
|
|
1411
|
+
pass
|
|
1412
|
+
|
|
1413
|
+
def in_run():
|
|
1414
|
+
req = ctx.get()
|
|
1415
|
+
if req:
|
|
1416
|
+
return bool(req.get("turn"))
|
|
1417
|
+
return run_seen["at"] is not None and time.monotonic() - run_seen["at"] <= run_live_s
|
|
1418
|
+
|
|
1419
|
+
# None to leave the call alone; hold for a write of the server itself; else the collection's
|
|
1420
|
+
# copy when there is one, or none yet for a write, which makes it.
|
|
1421
|
+
def guarded(url, method):
|
|
1422
|
+
call = qdrant_call(url, method)
|
|
1423
|
+
if not call:
|
|
1424
|
+
return None
|
|
1425
|
+
if not call["collection"]:
|
|
1426
|
+
return {"hold": True} if call["write"] and in_run() else None
|
|
1427
|
+
if our_copy.search(call["collection"]) or not in_run():
|
|
1428
|
+
return None
|
|
1429
|
+
key = "%s|%s" % (call["parts"].netloc, call["collection"])
|
|
1430
|
+
copy = copies.get(key)
|
|
1431
|
+
if copy:
|
|
1432
|
+
return {"key": key, "call": call, "copy": copy}
|
|
1433
|
+
if not call["write"]:
|
|
1434
|
+
return None
|
|
1435
|
+
if call["kind"] == "schema":
|
|
1436
|
+
return {"hold": True}
|
|
1437
|
+
if copied["collections"] >= copies_max or copied["points"] >= points_max:
|
|
1438
|
+
copy_say(call["parts"].netloc, call["collection"], "this run already copied %d collections, %d points" % (copied["collections"], copied["points"]))
|
|
1439
|
+
return {"hold": True}
|
|
1440
|
+
return {"key": key, "call": call, "copy": None}
|
|
1441
|
+
|
|
1442
|
+
# Qdrant's own API over urllib, which this file leaves unpatched: never through the app's clients.
|
|
1443
|
+
def qdrant_http(base, key, method, path, body=None):
|
|
1444
|
+
import urllib.error
|
|
1445
|
+
import urllib.request
|
|
1446
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
1447
|
+
headers = {"content-type": "application/json"}
|
|
1448
|
+
if key:
|
|
1449
|
+
headers["api-key"] = key
|
|
1450
|
+
req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
|
|
1451
|
+
try:
|
|
1452
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
1453
|
+
return json.loads(r.read().decode("utf-8", "replace") or "null"), r.status
|
|
1454
|
+
except urllib.error.HTTPError as e:
|
|
1455
|
+
return None, e.code
|
|
1456
|
+
|
|
1457
|
+
# The copy made: "empty" when the server holds no such collection yet (the app makes it, under
|
|
1458
|
+
# the copy's name), "points" once every point is across, None when none could be made.
|
|
1459
|
+
def clone_qdrant(base, key, source, clone):
|
|
1460
|
+
at = lambda name: "/collections/" + quote(name, safe="")
|
|
1461
|
+
info, status = qdrant_http(base, key, "GET", at(source))
|
|
1462
|
+
if status == 404:
|
|
1463
|
+
return ("empty", 0)
|
|
1464
|
+
result = (info or {}).get("result") if status == 200 else None
|
|
1465
|
+
if not result:
|
|
1466
|
+
return None
|
|
1467
|
+
if (result.get("points_count") or 0) > qdrant_cap:
|
|
1468
|
+
return None
|
|
1469
|
+
params = (result.get("config") or {}).get("params") or {}
|
|
1470
|
+
made = {"vectors": params.get("vectors")}
|
|
1471
|
+
if params.get("sparse_vectors"):
|
|
1472
|
+
made["sparse_vectors"] = params["sparse_vectors"]
|
|
1473
|
+
if "on_disk_payload" in params:
|
|
1474
|
+
made["on_disk_payload"] = params["on_disk_payload"]
|
|
1475
|
+
if qdrant_http(base, key, "PUT", at(clone), made)[1] != 200:
|
|
1476
|
+
return None
|
|
1477
|
+
try:
|
|
1478
|
+
for field, schema in (result.get("payload_schema") or {}).items():
|
|
1479
|
+
qdrant_http(base, key, "PUT", at(clone) + "/index?wait=true", {"field_name": field, "field_schema": schema.get("params") or schema.get("data_type")})
|
|
1480
|
+
offset = None
|
|
1481
|
+
while True:
|
|
1482
|
+
page, status = qdrant_http(base, key, "POST", at(source) + "/points/scroll", {"limit": 256, "with_payload": True, "with_vector": True, **({"offset": offset} if offset is not None else {})})
|
|
1483
|
+
if status != 200:
|
|
1484
|
+
raise RuntimeError(status)
|
|
1485
|
+
got = (page or {}).get("result") or {}
|
|
1486
|
+
points = got.get("points") or []
|
|
1487
|
+
if points and qdrant_http(base, key, "PUT", at(clone) + "/points?wait=true", {"points": [{"id": p.get("id"), "vector": p.get("vector"), "payload": p.get("payload")} for p in points]})[1] != 200:
|
|
1488
|
+
raise RuntimeError("upsert")
|
|
1489
|
+
offset = got.get("next_page_offset")
|
|
1490
|
+
if offset is None:
|
|
1491
|
+
return ("points", result.get("points_count") or 0)
|
|
1492
|
+
except Exception:
|
|
1493
|
+
qdrant_http(base, key, "DELETE", at(clone))
|
|
1494
|
+
return None
|
|
1495
|
+
|
|
1496
|
+
# The copy's name, made now when none exists; None when none could be made. The key the app
|
|
1497
|
+
# sent is the key the copy is made with, and never leaves this process.
|
|
1498
|
+
def copy_of(g, header_of):
|
|
1499
|
+
with copies_lock:
|
|
1500
|
+
have = copies.get(g["key"])
|
|
1501
|
+
if have:
|
|
1502
|
+
return have
|
|
1503
|
+
parts = g["call"]["parts"]
|
|
1504
|
+
base = "%s://%s" % (parts.scheme, parts.netloc)
|
|
1505
|
+
auth = str(header_of("authorization") or "")
|
|
1506
|
+
bearer = re.match(r"^Bearer\s+(\S+)", auth, re.I)
|
|
1507
|
+
key = str(header_of("api-key") or "") or (bearer.group(1) if bearer else "")
|
|
1508
|
+
clone = "%s_cortad_%d" % (g["call"]["collection"][:40], os.getpid())
|
|
1509
|
+
made = clone_qdrant(base, key, g["call"]["collection"], clone)
|
|
1510
|
+
if not made:
|
|
1511
|
+
copy_say(parts.netloc, g["call"]["collection"], "the copy did not go through")
|
|
1512
|
+
return None
|
|
1513
|
+
copied["collections"] += 1
|
|
1514
|
+
copied["points"] += made[1]
|
|
1515
|
+
copies[g["key"]] = clone
|
|
1516
|
+
write({"copy": {"host": parts.hostname or "", "base": base, "collection": g["call"]["collection"], "clone": clone}})
|
|
1517
|
+
return clone
|
|
1518
|
+
|
|
1519
|
+
def to_copy(url, clone, call):
|
|
1520
|
+
parts = call["parts"]
|
|
1521
|
+
return urlunsplit((parts.scheme, parts.netloc, "/collections/" + quote(clone, safe="") + call["rest"], parts.query, parts.fragment))
|
|
1522
|
+
|
|
1523
|
+
# The copy a call goes to, waited for off the event loop: None to hold the call instead.
|
|
1524
|
+
async def copy_for(g, header_of):
|
|
1525
|
+
import asyncio
|
|
1526
|
+
if g.get("hold"):
|
|
1527
|
+
return None
|
|
1528
|
+
return g["copy"] or await asyncio.get_running_loop().run_in_executor(None, copy_of, g, header_of)
|
|
1529
|
+
|
|
1356
1530
|
# A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
|
|
1357
1531
|
# where a SELECT can write through a function) is held.
|
|
1358
1532
|
# ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
|
|
@@ -2468,19 +2642,34 @@ def _install():
|
|
|
2468
2642
|
dep(request.url, response.status_code, req=req, caller=caller)
|
|
2469
2643
|
return response
|
|
2470
2644
|
|
|
2645
|
+
def held_answer(request, sent):
|
|
2646
|
+
kind = request.headers.get("content-type", "")
|
|
2647
|
+
held_row(request.url, request.method, sent, kind, ctx.get())
|
|
2648
|
+
return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
|
|
2649
|
+
|
|
2471
2650
|
# A held call that cannot be answered fails in the app; it is never sent instead.
|
|
2472
2651
|
def held_httpx(request, sent):
|
|
2473
2652
|
if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
|
|
2474
2653
|
return None
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2654
|
+
return held_answer(request, sent)
|
|
2655
|
+
|
|
2656
|
+
# A call to a Qdrant collection goes to the run's copy of it, `clone`; with none, it is held.
|
|
2657
|
+
def routed_httpx(request, g, clone, sent):
|
|
2658
|
+
if clone is None:
|
|
2659
|
+
return held_answer(request, sent)
|
|
2660
|
+
request.url = module.URL(to_copy(request.url, clone, g["call"]))
|
|
2661
|
+
return None
|
|
2478
2662
|
|
|
2479
2663
|
for cls in (module.Client, module.AsyncClient):
|
|
2480
2664
|
send = cls.send
|
|
2481
2665
|
if cls is module.Client:
|
|
2482
2666
|
def sync_send(self, request, *a, _send=send, **k):
|
|
2483
2667
|
sent = sent_of(request)
|
|
2668
|
+
g = guarded(request.url, request.method)
|
|
2669
|
+
if g is not None:
|
|
2670
|
+
answered = routed_httpx(request, g, None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get)), sent)
|
|
2671
|
+
if answered is not None:
|
|
2672
|
+
return answered
|
|
2484
2673
|
held = held_httpx(request, sent)
|
|
2485
2674
|
if held is not None:
|
|
2486
2675
|
return held
|
|
@@ -2504,6 +2693,11 @@ def _install():
|
|
|
2504
2693
|
else:
|
|
2505
2694
|
async def async_send(self, request, *a, _send=send, **k):
|
|
2506
2695
|
sent = sent_of(request)
|
|
2696
|
+
g = guarded(request.url, request.method)
|
|
2697
|
+
if g is not None:
|
|
2698
|
+
answered = routed_httpx(request, g, await copy_for(g, request.headers.get), sent)
|
|
2699
|
+
if answered is not None:
|
|
2700
|
+
return answered
|
|
2507
2701
|
held = held_httpx(request, sent)
|
|
2508
2702
|
if held is not None:
|
|
2509
2703
|
return held
|
|
@@ -2535,6 +2729,9 @@ def _install():
|
|
|
2535
2729
|
raw = text(request.body, reply_max)
|
|
2536
2730
|
if reads_only(raw, request.url, request.headers.get):
|
|
2537
2731
|
return None
|
|
2732
|
+
return held_answer(request, raw)
|
|
2733
|
+
|
|
2734
|
+
def held_answer(request, raw):
|
|
2538
2735
|
import datetime
|
|
2539
2736
|
models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
|
|
2540
2737
|
kind = request.headers.get("content-type", "")
|
|
@@ -2546,6 +2743,12 @@ def _install():
|
|
|
2546
2743
|
return response
|
|
2547
2744
|
|
|
2548
2745
|
def sent(self, request, *a, **k):
|
|
2746
|
+
g = guarded(request.url, request.method)
|
|
2747
|
+
if g is not None:
|
|
2748
|
+
clone = None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get))
|
|
2749
|
+
if clone is None:
|
|
2750
|
+
return held_answer(request, text(request.body, reply_max))
|
|
2751
|
+
request.url = to_copy(request.url, clone, g["call"])
|
|
2549
2752
|
answered = held(request)
|
|
2550
2753
|
if answered is not None:
|
|
2551
2754
|
return answered
|
|
@@ -2601,7 +2804,18 @@ def _install():
|
|
|
2601
2804
|
async def requested(self, method, str_or_url, *a, **k):
|
|
2602
2805
|
body = k.get("json") if k.get("json") is not None else k.get("data")
|
|
2603
2806
|
url = whole(self, str_or_url)
|
|
2604
|
-
|
|
2807
|
+
g = guarded(url, method)
|
|
2808
|
+
hold = False
|
|
2809
|
+
if g is not None:
|
|
2810
|
+
clone = await copy_for(g, lambda name: header_of(self, k, name))
|
|
2811
|
+
if clone is None:
|
|
2812
|
+
hold = True
|
|
2813
|
+
else:
|
|
2814
|
+
parts = g["call"]["parts"]
|
|
2815
|
+
url = to_copy(url, clone, g["call"])
|
|
2816
|
+
# A session with a base address takes the path alone.
|
|
2817
|
+
str_or_url = url if getattr(self, "_base_url", None) is None else url[len("%s://%s" % (parts.scheme, parts.netloc)):]
|
|
2818
|
+
if hold or (held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name))):
|
|
2605
2819
|
port = held_port()
|
|
2606
2820
|
if port is None:
|
|
2607
2821
|
raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
|
package/lib/qdrant.mjs
CHANGED
|
@@ -5,8 +5,9 @@ export const QDRANT_CAP = 200_000;
|
|
|
5
5
|
const BATCH = 256;
|
|
6
6
|
const at = (name) => `/collections/${encodeURIComponent(name)}`;
|
|
7
7
|
|
|
8
|
+
// `conn.fetch`: the fetch to use, when the caller's global one is wrapped (the hook in the app).
|
|
8
9
|
async function call(conn, method, path, body) {
|
|
9
|
-
const res = await fetch(`${conn.base}${path}`, {
|
|
10
|
+
const res = await (conn.fetch ?? fetch)(`${conn.base}${path}`, {
|
|
10
11
|
method,
|
|
11
12
|
headers: { "content-type": "application/json", ...(conn.apiKey ? { "api-key": conn.apiKey } : {}) },
|
|
12
13
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -34,7 +35,7 @@ export async function cloneQdrant(conn, source, clone) {
|
|
|
34
35
|
const page = await call(conn, "POST", `${at(source)}/points/scroll`, { limit: BATCH, with_payload: true, with_vector: true, ...(offset === undefined ? {} : { offset }) });
|
|
35
36
|
if (page.points.length) await call(conn, "PUT", `${at(clone)}/points?wait=true`, { points: page.points.map(({ id, vector, payload }) => ({ id, vector, payload })) });
|
|
36
37
|
offset = page.next_page_offset;
|
|
37
|
-
if (offset === null || offset === undefined) return { how: "points" };
|
|
38
|
+
if (offset === null || offset === undefined) return { how: "points", points: info.points_count ?? 0 };
|
|
38
39
|
}
|
|
39
40
|
} catch (e) {
|
|
40
41
|
if (made) await dropQdrant(conn, clone);
|
package/lib/reach.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { plural, upper } from "./words.mjs";
|
|
|
4
4
|
|
|
5
5
|
// One request to each endpoint status lists as not reached, from this machine to the app on its port.
|
|
6
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,
|
|
7
|
+
// The connect command sends these by itself, up to four at a time, each tagged x-cortad-turn: reach:auto:<n>
|
|
8
8
|
// (sendOurs), signed in only as a test account this machine made. Headers are never the agent's: they
|
|
9
9
|
// go from here to the app on this machine and nowhere else, and only their names are printed. These
|
|
10
10
|
// are real requests with real effects.
|
|
@@ -31,7 +31,7 @@ export function notUp(runner) {
|
|
|
31
31
|
// The rows a call sends and the ones it leaves: the named paths, else every row with a ready request
|
|
32
32
|
// that is not marked by hand.
|
|
33
33
|
export function picked(contact, receipts, paths = []) {
|
|
34
|
-
const rows = unreached(contact, receipts);
|
|
34
|
+
const rows = unreached(contact, receipts, { retests: true });
|
|
35
35
|
const named = new Set(Array.isArray(paths) ? paths : []);
|
|
36
36
|
const chosen = named.size ? rows.filter((m) => named.has(m.path) || named.has(keyOf(m))) : rows.filter((m) => !m.unsure);
|
|
37
37
|
const sent = chosen.filter((m) => m.request);
|
|
@@ -72,15 +72,15 @@ export function reachText({ rows, sent, left, unknown }, answers, how) {
|
|
|
72
72
|
].filter(Boolean).join("\n");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// Cortad's own test requests, sent by the connect command
|
|
76
|
-
// belongs to one of them, each with the app's own origin where one is known. An endpoint that answers
|
|
75
|
+
// Cortad's own test requests, sent by the connect command, each with the app's own origin where one is known. An endpoint that answers
|
|
77
76
|
// 401 or 403 is asked once more as the test account this machine signed in, when there is one
|
|
78
77
|
// (`signIn(row)` gives { name, value } or null); the token never leaves this machine.
|
|
79
78
|
// `stop()`: true when the rest should wait, for the agent's ask; the answers so far come back.
|
|
80
79
|
// Four at a time: one each in turn left a 22-endpoint app waiting nearly two minutes behind its two
|
|
81
80
|
// slowest replies (ulaim, 2026-10-06). Answers keep the rows' order; a row is not sent once stop() says so.
|
|
81
|
+
// `onAnswer(answer, i)`: called as each row's answer lands, so one slow endpoint holds back no other's.
|
|
82
82
|
const AT_ONCE = 4;
|
|
83
|
-
export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, fetchImpl = fetch, atOnce = AT_ONCE }) {
|
|
83
|
+
export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, onAnswer = () => {}, fetchImpl = fetch, atOnce = AT_ONCE }) {
|
|
84
84
|
const app = appUrl(runner);
|
|
85
85
|
const base = () => ({ ...(origin ? { origin, referer: `${origin}/` } : {}), [RUN_TURN]: `reach:auto:${next()}` });
|
|
86
86
|
const answers = [];
|
|
@@ -101,6 +101,7 @@ export async function sendOurs(rows, { runner, origin, signIn = async () => null
|
|
|
101
101
|
if (stopped || stop()) { stopped = true; return; }
|
|
102
102
|
const i = at++;
|
|
103
103
|
answers[i] = await one(rows[i]);
|
|
104
|
+
try { onAnswer(answers[i], i); } catch { /* the answer stands whatever its listener did */ }
|
|
104
105
|
}
|
|
105
106
|
};
|
|
106
107
|
await Promise.all(Array.from({ length: Math.min(atOnce, rows.length) }, lane));
|
|
@@ -179,7 +180,13 @@ export async function sendOne(m, app, headers, fetchImpl, secrets = []) {
|
|
|
179
180
|
res = await fetchImpl(url, { method, headers: json && !typed ? { "content-type": "application/json", ...headers } : headers,
|
|
180
181
|
...(json ? { body: JSON.stringify(body) } : {}), redirect: "manual", signal: AbortSignal.timeout(REACH_MS) });
|
|
181
182
|
} catch (err) {
|
|
182
|
-
|
|
183
|
+
const code = err?.cause?.code ?? err?.code ?? "";
|
|
184
|
+
// Refused, nothing reached the app, and a GET cut off changes nothing: both are sent again
|
|
185
|
+
// (`down`). A POST whose connection was reset may have been taken by the app before it went, so
|
|
186
|
+
// it is an answer nobody saw (`unknown`) and is never sent again by itself.
|
|
187
|
+
const unknown = code === "ECONNRESET" && method !== "GET";
|
|
188
|
+
return { line: `${label}: ${failedSaid(err, app.port || 80)}${unknown ? " It may have reached your app, so Cortad does not send it again by itself." : ""}`, ok: false, status: 0,
|
|
189
|
+
down: !unknown && /^(?:ECONNREFUSED|ECONNRESET)$/.test(code), ...(unknown ? { unknown: true } : {}) };
|
|
183
190
|
}
|
|
184
191
|
let text;
|
|
185
192
|
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 }; }
|
package/lib/replay.mjs
CHANGED
|
@@ -37,6 +37,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
37
37
|
let newestTable = null;
|
|
38
38
|
// Each server a hooked process opened a connection to, by host and port.
|
|
39
39
|
const conns = new Map();
|
|
40
|
+
// The Qdrant collections the hook copied for a run's writes, by server and name (lib/stores.mjs drops them).
|
|
41
|
+
const copies = new Map();
|
|
40
42
|
// The model's last words and status for each of a run's turns on an endpoint the app answers
|
|
41
43
|
// later, by turn (lib/wire.cjs answeredLater).
|
|
42
44
|
const answers = new Map();
|
|
@@ -48,7 +50,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
48
50
|
// requires, to one without.
|
|
49
51
|
const bunHook = /\s/.test(HOOK) ? (() => {
|
|
50
52
|
const dir = mkdtempSync(join(tmpdir(), "cortad-"));
|
|
51
|
-
for (const name of ["trace.cjs", "wire.cjs", "tied.cjs"]) copyFileSync(join(HERE, name), join(dir, name));
|
|
53
|
+
for (const name of ["trace.cjs", "wire.cjs", "tied.cjs", "qdrant.mjs"]) copyFileSync(join(HERE, name), join(dir, name));
|
|
52
54
|
return join(dir, "trace.cjs");
|
|
53
55
|
})() : HOOK;
|
|
54
56
|
// The hooks are handed to whatever is started: a Node app loads the first, a Bun app the same file
|
|
@@ -96,6 +98,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
96
98
|
if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
|
|
97
99
|
if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
|
|
98
100
|
if (row.conn) { const { host, port } = row.conn; if (typeof host === "string" && Number.isInteger(port)) conns.set(`${host}:${port}`, { host, port }); continue; }
|
|
101
|
+
if (row.copy) { const { base, collection, clone } = row.copy; if ([base, collection, clone].every((v) => typeof v === "string" && v)) copies.set(`${base}|${collection}`, { base, collection, clone }); continue; }
|
|
99
102
|
if (row.answer) {
|
|
100
103
|
const { turn, status, reply } = row.answer;
|
|
101
104
|
if (TURN.test(turn ?? "")) answers.set(turn, { status: Number.isInteger(status) ? status : 0, reply: typeof reply === "string" ? reply : "" });
|
|
@@ -158,6 +161,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
158
161
|
registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
|
|
159
162
|
// The servers the app has connected to so far (lib/stores.mjs names the ones no setting does).
|
|
160
163
|
connections: () => { poll(); return [...conns.values()]; },
|
|
164
|
+
// The collections the hook copied on a Qdrant server for a run's writes, to be deleted at the end.
|
|
165
|
+
copies: () => { poll(); return [...copies.values()]; },
|
|
161
166
|
// The model's last words for a run's turn on an endpoint the app answers later, once the hook
|
|
162
167
|
// has written them; null at `until`, or once `signal` aborts. `forget` drops a turn sent again.
|
|
163
168
|
answered: async (turn, until, signal) => {
|
package/lib/spec.mjs
CHANGED
|
@@ -11,10 +11,20 @@ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import
|
|
|
11
11
|
export const SPEC = "(?:cortad(?:@[A-Za-z0-9._-]+)?|/[A-Za-z0-9._/-]+)";
|
|
12
12
|
const SAFE = new RegExp(`^${SPEC}$`);
|
|
13
13
|
|
|
14
|
+
// A pin older than this very version loses to it: a connect run as cortad@0.3.8 wrote a skill and an
|
|
15
|
+
// MCP server pinned to 0.3.7, and the agent asked whether the two were meant to mix.
|
|
14
16
|
export function cliSpec({ env = process.env, version = VERSION } = {}) {
|
|
15
|
-
|
|
16
|
-
return `cortad@${version}`;
|
|
17
|
+
const pinned = env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC) ? env.CORTAD_CLI_SPEC : null;
|
|
18
|
+
if (!pinned) return `cortad@${version}`;
|
|
19
|
+
const released = /^cortad@(\d+\.\d+\.\d+)$/.exec(pinned)?.[1];
|
|
20
|
+
return released && newer(version, released) ? `cortad@${version}` : pinned;
|
|
17
21
|
}
|
|
22
|
+
const newer = (a, b) => {
|
|
23
|
+
if (!/^\d+\.\d+\.\d+$/.test(a)) return false;
|
|
24
|
+
const [x, y] = [a, b].map((v) => v.split(".").map(Number));
|
|
25
|
+
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] > y[i];
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
18
28
|
|
|
19
29
|
// npx needs -y to fetch a package without asking; a local folder is not fetched.
|
|
20
30
|
export const npxArgs = (spec) => (isAbsolute(spec) ? [spec] : ["-y", spec]);
|
package/lib/stores.mjs
CHANGED
|
@@ -249,6 +249,12 @@ const heldWhy = (s, why, tool) => {
|
|
|
249
249
|
const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
|
|
250
250
|
return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)} off this machine, and ${reason}, so the run would write into it as it is. ${fix}, then run the connect command again.`;
|
|
251
251
|
};
|
|
252
|
+
// A Qdrant server the app writes into is kept by the hook in the app instead of by a copy made here:
|
|
253
|
+
// the first write a run makes into a collection copies that collection on the same server and the
|
|
254
|
+
// app's later calls go to the copy (lib/trace.cjs, lib/pyhook/sitecustomize.py). So a hosted Qdrant
|
|
255
|
+
// holds nothing, and a local one whose collection no setting names is not a risk either.
|
|
256
|
+
export const GUARDED_ENGINES = new Set(["qdrant"]);
|
|
257
|
+
const guardedKept = (s) => `Your app's ${ENGINE[s.engine]} collections at ${listed(s.names)} are not written into by a run: the first time a run writes into one, that collection is copied on the same server and the app's calls go to the copy, which is deleted when this command ends.`;
|
|
252
258
|
const hostedKept = (s, got) => (got.how === "neon"
|
|
253
259
|
? `Your app runs against a Neon branch of its ${whatOf(s)}, made for this session through ${listed(s.names)}; the original is not touched, and the branch is deleted when this command ends and made again from the original at the next connect.`
|
|
254
260
|
: got.how === "supabase"
|
|
@@ -259,7 +265,7 @@ export const storeId = (s) => createHash("sha256").update(s.key).digest("hex").s
|
|
|
259
265
|
// A store off this machine the app was pointed at a copy of and reached anyway (dotenv's override):
|
|
260
266
|
// trials write into the original, so it holds Run. `blocked`: the rows already holding it.
|
|
261
267
|
export const bypassedOf = (connections, stores, blocked) => stores
|
|
262
|
-
.filter((s) => !onThisMachine(s.host) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
|
|
268
|
+
.filter((s) => !onThisMachine(s.host) && !GUARDED_ENGINES.has(s.engine) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
|
|
263
269
|
.map((s) => ({ id: storeId(s), names: s.names, why: heldWhy(s, "bypassed") }));
|
|
264
270
|
|
|
265
271
|
// A copy that throws is one that did not go through, said as such; it never stops the app starting.
|
|
@@ -292,6 +298,12 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
|
|
|
292
298
|
const pass = [];
|
|
293
299
|
for (const s of stores) {
|
|
294
300
|
if (done.has(s.key)) continue;
|
|
301
|
+
if (GUARDED_ENGINES.has(s.engine) && (!onThisMachine(s.host) || !s.points.length || !s.db)) {
|
|
302
|
+
if (await down(s)) continue;
|
|
303
|
+
done.add(s.key);
|
|
304
|
+
copies.push(guardedKept(s));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
295
307
|
if (!onThisMachine(s.host)) {
|
|
296
308
|
done.add(s.key);
|
|
297
309
|
const got = started ? await hosted.keep(s).catch(() => ({ why: ["other"] })) : { why: ["started"] };
|
|
@@ -329,6 +341,21 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
|
|
|
329
341
|
for (const { s, row } of made.splice(0)) if (await drop(s, row.made, row.marker)) book.remove(row);
|
|
330
342
|
await hosted.drop();
|
|
331
343
|
},
|
|
344
|
+
// The collections the hook in the app copied for a run's writes (lib/trace.cjs copyOf), deleted
|
|
345
|
+
// with the key the settings hold for that server. Each is in the ledger from the moment it is
|
|
346
|
+
// seen, so one a killed session left is deleted by the next.
|
|
347
|
+
// ponytail: a server no setting names a key for is asked without one; a copy that refuses stays
|
|
348
|
+
// under its _cortad_ name for the person to see. The key the app sent never leaves the app.
|
|
349
|
+
async dropCopies(copies, stores) {
|
|
350
|
+
const rows = copies.map((c) => ({ kind: "qdrant-copy", key: c.base, made: c.clone }));
|
|
351
|
+
for (const row of rows) book.add(row);
|
|
352
|
+
const hostOf = (base) => { try { return new URL(base).hostname; } catch { return base; } };
|
|
353
|
+
for (const row of [...rows, ...book.orphans("qdrant-copy")]) {
|
|
354
|
+
// The hook names the server with its port and QDRANT_URL may not: the key is the host's.
|
|
355
|
+
const conn = { base: row.key, apiKey: stores.find((s) => s.engine === "qdrant" && s.conn?.base && hostOf(s.conn.base) === hostOf(row.key))?.conn.apiKey };
|
|
356
|
+
if (await dropQdrant(conn, row.made)) book.remove(row);
|
|
357
|
+
}
|
|
358
|
+
},
|
|
332
359
|
};
|
|
333
360
|
}
|
|
334
361
|
|
|
@@ -341,7 +368,7 @@ export function unnamedHeld(connections, stores, blocked) {
|
|
|
341
368
|
for (const { host, port } of connections) {
|
|
342
369
|
const local = onThisMachine(host);
|
|
343
370
|
const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0] ?? ENGINE_AT[port];
|
|
344
|
-
if (!engine || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
|
|
371
|
+
if (!engine || GUARDED_ENGINES.has(engine) || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
|
|
345
372
|
const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
|
|
346
373
|
if ([...blocked, ...rows].some((b) => b.id === id)) continue;
|
|
347
374
|
// Named by its engine where a setting's name would stand: the API takes a name, never an address.
|
package/lib/text.mjs
CHANGED
|
@@ -100,8 +100,16 @@ const runsSaid = (left, allowed) => (left > 0 ? `${num(left)} of ${plural(allowe
|
|
|
100
100
|
// After the findings: what was measured of the app's own promises, in its own words, then the checks
|
|
101
101
|
// measured and why the rest were not, then how many trials kept every promise. Counts, never a score.
|
|
102
102
|
// The replies that did not count sit before the count: a clean count beside 62 error replies read as a clean bill.
|
|
103
|
+
// Their own eval files, as the run played them: one line for all, one per file.
|
|
104
|
+
function evalLines(evals) {
|
|
105
|
+
const t = evals?.total;
|
|
106
|
+
if (!t || !t.played) return [];
|
|
107
|
+
const said = (x) => `${num(x.passed)} of ${plural(x.played, "case")} passed${x.failed ? `, ${num(x.failed)} failed` : ""}${x.unclear ? `, ${num(x.unclear)} not settled` : ""}${x.cases > x.played ? `, ${num(x.cases - x.played)} not played` : ""}`;
|
|
108
|
+
return [`Your own eval files: ${said(t)}.`, ...(evals.files ?? []).slice(0, 12).map((f) => ` ${f.file}: ${said(f)}.`)];
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
function measuredLines(d) {
|
|
104
|
-
const lines = [...promiseLines(d.promises), ...failedLines(d.failedReplies)];
|
|
112
|
+
const lines = [...promiseLines(d.promises), ...evalLines(d.evals), ...failedLines(d.failedReplies)];
|
|
105
113
|
const of = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0;
|
|
106
114
|
if (of) lines.push(`${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured.`);
|
|
107
115
|
// The dissection says what was not measured in its own line.
|
|
@@ -392,7 +400,14 @@ function findingsTail(d) {
|
|
|
392
400
|
function findingsPages(d, budget = PAGE_CHARS - 60) {
|
|
393
401
|
// Unrecorded is not unused (dissection-text.mjs): no tool is said never to have run.
|
|
394
402
|
const unseen = (d.tools ?? d.dissection?.tools)?.recorded === false;
|
|
395
|
-
|
|
403
|
+
// The findings from their own eval files are headed as theirs, once, where the first one stands.
|
|
404
|
+
let headed = false;
|
|
405
|
+
const blocks = [...(d.findings ?? []).map((f) => {
|
|
406
|
+
const block = findingBlock(f, unseen);
|
|
407
|
+
if (f.group !== "eval" || headed) return block;
|
|
408
|
+
headed = true;
|
|
409
|
+
return `From your own eval files:\n${block}`;
|
|
410
|
+
}), findingsTail(d)].filter(Boolean);
|
|
396
411
|
const pages = [];
|
|
397
412
|
let page = findingsHead(d);
|
|
398
413
|
let filled = false;
|
package/lib/trace.cjs
CHANGED
|
@@ -17,7 +17,7 @@ if (FILE) {
|
|
|
17
17
|
const http = require("node:http");
|
|
18
18
|
const https = require("node:https");
|
|
19
19
|
const als = new AsyncLocalStorage();
|
|
20
|
-
const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, toolText, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames, makeRecorder, asChat, GATEWAY_MODEL } = require("./wire.cjs");
|
|
20
|
+
const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, toolText, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, qdrantCall, passagesFrom, decoded, cookieNames, makeRecorder, asChat, GATEWAY_MODEL } = require("./wire.cjs");
|
|
21
21
|
const row = (value) => { try { fs.appendFileSync(FILE, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
|
|
22
22
|
// What the app writes into its own folder while a run is on: each file copied once before its
|
|
23
23
|
// first write, so the command puts every one back when the run ends (lib/writes.mjs). Trials
|
|
@@ -798,15 +798,87 @@ if (FILE) {
|
|
|
798
798
|
// ponytail: while a run is live, a person's own queued write is held too; per-ask pinning of
|
|
799
799
|
// background writes is the upgrade when that matters.
|
|
800
800
|
const RUN_LIVE_MS = 10 * 60_000;
|
|
801
|
+
// The copies this hook makes of Qdrant collections carry this suffix (copyOf below).
|
|
802
|
+
const OUR_COPY = /_cortad_\d+$/;
|
|
801
803
|
const heldHere = (u, method, headerOf) => {
|
|
802
804
|
const ctx = als.getStore();
|
|
803
805
|
if (!WRITES.test(method) || (ctx ? !ctx.turn : Date.now() - lastTurnAt > RUN_LIVE_MS)) return false;
|
|
806
|
+
// A read of a Qdrant collection, however its server is named (a bare address holds no word
|
|
807
|
+
// like qdrant), and any call to a copy this hook made, go out.
|
|
808
|
+
const q = qdrantCall(u, method);
|
|
809
|
+
if (q && (!q.write || (q.collection && OUR_COPY.test(q.collection)))) return false;
|
|
804
810
|
try {
|
|
805
811
|
const host = targetOf(u, headerOf);
|
|
806
812
|
if (THIS_MACHINE.test(host) || SANDBOX_HOST.test(host) || isModelCall(u.host, u.pathname) || isRetrieval(u) || TOKEN_PATH.test(u.pathname) || readsByName(method, u.pathname) || KEY_HEADERS.some((name) => testKey(authText(headerOf(name))))) return false;
|
|
807
813
|
return !passNow().some((p) => host === p || host.endsWith("." + p));
|
|
808
814
|
} catch { return true; }
|
|
809
815
|
};
|
|
816
|
+
// A write to a Qdrant collection made inside a run goes to a copy of that collection on the same
|
|
817
|
+
// server, made at the first such write: the collection is read page by page and written under
|
|
818
|
+
// `<name>_cortad_<pid>`, and from then on every call to that collection, reads too, goes to the
|
|
819
|
+
// copy, so the app finds what it wrote. The original is never written. A copy that cannot be
|
|
820
|
+
// made (a collection over the size a run copies, a server that refused) leaves the write held
|
|
821
|
+
// like any other. Each copy is reported as a `copy` row, and the command deletes them when it
|
|
822
|
+
// ends. A copy of ours is never copied again. ulaim's hosted Qdrant held Run for the person's
|
|
823
|
+
// yes for seventeen minutes; this is what the yes stood in for.
|
|
824
|
+
const copies = new Map();
|
|
825
|
+
// What one process copies at most: collections, and points across them. Past it a write is held
|
|
826
|
+
// like any other, and the app's output says so once. A run is a few conversations' writes, never
|
|
827
|
+
// a cluster's.
|
|
828
|
+
const COPIES_MAX = 8;
|
|
829
|
+
const POINTS_MAX = 300_000;
|
|
830
|
+
let copied = 0, pointsCopied = 0;
|
|
831
|
+
const nativeFetch = typeof globalThis.fetch === "function" ? globalThis.fetch : null;
|
|
832
|
+
let qdrantLib = null;
|
|
833
|
+
const qdrantMod = () => (qdrantLib ??= import(require("node:url").pathToFileURL(require("node:path").join(__dirname, "qdrant.mjs")).href));
|
|
834
|
+
const inRun = () => { const ctx = als.getStore(); return ctx ? Boolean(ctx.turn) : Date.now() - lastTurnAt <= RUN_LIVE_MS; };
|
|
835
|
+
// What to do with a call: null to leave it alone; `hold` for a write of the server itself
|
|
836
|
+
// (/collections/aliases); else the collection's copy when there is one, or none yet for a write
|
|
837
|
+
// in a run, which makes it.
|
|
838
|
+
const guarded = (u, method) => {
|
|
839
|
+
if (!/^https?:$/.test(u.protocol)) return null;
|
|
840
|
+
const call = qdrantCall(u, method);
|
|
841
|
+
if (!call) return null;
|
|
842
|
+
if (!call.collection) return call.write && inRun() ? { hold: true } : null;
|
|
843
|
+
// The person's own calls, outside a run, go where they were sent: their writes are theirs to keep.
|
|
844
|
+
if (OUR_COPY.test(call.collection) || !inRun()) return null;
|
|
845
|
+
const key = `${u.host}|${call.collection}`;
|
|
846
|
+
const copy = copies.get(key);
|
|
847
|
+
if (copy) return { key, call, copy };
|
|
848
|
+
if (!call.write) return null;
|
|
849
|
+
// A change to the collection's shape, or a write past this process's copy budget, is held.
|
|
850
|
+
if (call.kind === "schema") return { hold: true };
|
|
851
|
+
if (copied >= COPIES_MAX || pointsCopied >= POINTS_MAX) { copySay(u.host, call.collection, `this run already copied ${copied} collections, ${pointsCopied} points`); return { hold: true }; }
|
|
852
|
+
return { key, call, copy: null };
|
|
853
|
+
};
|
|
854
|
+
// Said once per collection in the app's own output when no copy could be made and the write was
|
|
855
|
+
// kept on this machine instead, with the reason the server gave.
|
|
856
|
+
const copySaid = new Set();
|
|
857
|
+
const copySay = (host, collection, why) => {
|
|
858
|
+
if (copySaid.has(`${host}|${collection}`)) return;
|
|
859
|
+
copySaid.add(`${host}|${collection}`);
|
|
860
|
+
try { process.stderr.write(`cortad: a request Cortad sent made this app write into the Qdrant collection ${collection} at ${host}. Cortad could not copy that collection on its server (${why}), so the write was kept on this machine and answered with a stand-in success.\n`); } catch { /* no stderr */ }
|
|
861
|
+
};
|
|
862
|
+
const toCopy = (u, clone, call) => { const t = new URL(u.href); t.pathname = `/collections/${encodeURIComponent(clone)}${call.rest}`; return t; };
|
|
863
|
+
// The copy's name, made now when none exists; null when none could be made. The key the app sent
|
|
864
|
+
// is the key the copy is made with, and never leaves this process.
|
|
865
|
+
const copyOf = (u, headerOf, g) => {
|
|
866
|
+
const have = copies.get(g.key);
|
|
867
|
+
if (have) return Promise.resolve(have);
|
|
868
|
+
if (!nativeFetch) return Promise.resolve(null);
|
|
869
|
+
const clone = `${g.call.collection.slice(0, 40)}_cortad_${process.pid}`;
|
|
870
|
+
const bearer = /^Bearer\s+(\S+)/i.exec(headerOf("authorization") || "");
|
|
871
|
+
const conn = { base: `${u.protocol}//${u.host}`, apiKey: headerOf("api-key") || (bearer ? bearer[1] : ""), fetch: nativeFetch };
|
|
872
|
+
const made = qdrantMod().then(({ cloneQdrant }) => cloneQdrant(conn, g.call.collection, clone)).then((got) => {
|
|
873
|
+
if (!got || got.why) { copies.delete(g.key); copySay(u.host, g.call.collection, got ? got.why : "nothing"); return null; }
|
|
874
|
+
copied += 1; pointsCopied += got.points ?? 0;
|
|
875
|
+
copies.set(g.key, clone);
|
|
876
|
+
row({ copy: { host: u.hostname, base: conn.base, collection: g.call.collection, clone } });
|
|
877
|
+
return clone;
|
|
878
|
+
}, (e) => { copies.delete(g.key); copySay(u.host, g.call.collection, e && e.message ? e.message : "error"); return null; });
|
|
879
|
+
copies.set(g.key, made);
|
|
880
|
+
return made;
|
|
881
|
+
};
|
|
810
882
|
// A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (Neon's SQL, where
|
|
811
883
|
// a SELECT can write through a function) is held.
|
|
812
884
|
// ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
|
|
@@ -878,23 +950,31 @@ if (FILE) {
|
|
|
878
950
|
heldWaiting.delete(id);
|
|
879
951
|
const sent = Buffer.concat(parts);
|
|
880
952
|
const text = sent.toString("utf8").slice(0, MAX);
|
|
881
|
-
|
|
882
|
-
// sent,
|
|
883
|
-
|
|
953
|
+
const headerOf = (name) => String(req.headers[name] || "");
|
|
954
|
+
// The call goes on to where it was sent, as it was sent, or to `to` instead, and its
|
|
955
|
+
// answer comes back through this server.
|
|
956
|
+
const forward = (to) => {
|
|
884
957
|
const { "x-cortad-held": _held, host: _host, connection: _connection, ...headers } = req.headers;
|
|
885
958
|
const given = typeof w.args[0] === "string" || w.args[0] instanceof URL;
|
|
886
|
-
const options = { ...((given ? w.args[1] : w.args[0]) || {}), headers, signal: undefined, [PASSED]: true };
|
|
959
|
+
const options = { ...((given ? w.args[1] : w.args[0]) || {}), headers, signal: undefined, [PASSED]: true, ...(to && !given ? { path: to.pathname + to.search } : {}) };
|
|
887
960
|
// Sent inside the request that asked, so the call is recorded behind that reply.
|
|
888
|
-
const send = () => (w.secure ? https : http).request(...(given ? [w.args[0], options] : [options]));
|
|
961
|
+
const send = () => (w.secure ? https : http).request(...(given ? [to ? to.href : w.args[0], options] : [options]));
|
|
889
962
|
const up = w.ctx ? als.run(w.ctx, send) : send();
|
|
890
963
|
up.on("response", (got) => { res.writeHead(got.statusCode || 502, got.headers); got.pipe(res); });
|
|
891
964
|
up.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
|
892
965
|
res.on("close", () => up.destroy());
|
|
893
966
|
return up.end(sent);
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
967
|
+
};
|
|
968
|
+
const answer = () => {
|
|
969
|
+
if (w) heldRow(w.u, w.method, text, req.headers["content-type"], w.ctx);
|
|
970
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
971
|
+
res.end(heldReply(text, req.headers["content-type"]));
|
|
972
|
+
};
|
|
973
|
+
// A body that only reads, known now that it is here, goes on as it was sent.
|
|
974
|
+
if (w && parts.length < 256 && readsOnly(text, w.u, headerOf, w.port)) return forward(null);
|
|
975
|
+
// A call to a Qdrant collection goes to the run's copy of it, made now when this is its first write.
|
|
976
|
+
if (w && w.copy) return copyOf(w.u, headerOf, w.copy).then((clone) => (clone ? forward(toCopy(w.u, clone, w.copy.call)) : answer()), answer);
|
|
977
|
+
answer();
|
|
898
978
|
});
|
|
899
979
|
});
|
|
900
980
|
server[HELD] = true;
|
|
@@ -917,12 +997,19 @@ if (FILE) {
|
|
|
917
997
|
const method = String(o.method || "GET").toUpperCase();
|
|
918
998
|
const u = new URL(`${secure ? "https" : "http"}://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}${path.startsWith("/") ? path : "/" + path}`);
|
|
919
999
|
const headerOf = (name) => headerIn(o.headers, name) || (name === "authorization" && o.auth ? `Basic ${Buffer.from(String(o.auth)).toString("base64")}` : "");
|
|
920
|
-
|
|
1000
|
+
const port = o.port || (given && given.port) || "";
|
|
1001
|
+
// The server's address whole, port included, names a collection's copy; the hold is decided on
|
|
1002
|
+
// the host alone, as before.
|
|
1003
|
+
const at = new URL(u.href);
|
|
1004
|
+
if (port) at.port = String(port);
|
|
1005
|
+
const g = guarded(at, method);
|
|
1006
|
+
if (!g && !heldHere(u, method, headerOf)) return null;
|
|
1007
|
+
return { u: at, method, o, args: args.filter((a) => typeof a !== "function"), port, cb: args.find((a) => typeof a === "function"), ...(g && !g.hold ? { copy: g } : {}) };
|
|
921
1008
|
};
|
|
922
1009
|
const httpRequest = http.request;
|
|
923
|
-
const sendHeld = ({ u, method, o, cb, args, port }, secure) => {
|
|
1010
|
+
const sendHeld = ({ u, method, o, cb, args, port, copy }, secure) => {
|
|
924
1011
|
const id = String(++heldSent);
|
|
925
|
-
heldWaiting.set(id, { u, method, ctx: als.getStore(), args, port, secure });
|
|
1012
|
+
heldWaiting.set(id, { u, method, ctx: als.getStore(), args, port, secure, copy });
|
|
926
1013
|
const headers = { ...(o.headers && typeof o.headers === "object" && !Array.isArray(o.headers) ? o.headers : {}), "x-cortad-held": id };
|
|
927
1014
|
// Port 1 answers nothing: a write held before the server listens fails instead of leaving.
|
|
928
1015
|
const req = httpRequest({ host: "127.0.0.1", port: heldPort || 1, method, path: u.pathname + u.search, headers, ...(o.timeout ? { timeout: o.timeout } : {}), ...(o.signal ? { signal: o.signal } : {}) }, cb);
|
|
@@ -943,15 +1030,31 @@ if (FILE) {
|
|
|
943
1030
|
const bodyText = (body) => (typeof body === "string" ? body : Buffer.isBuffer(body) ? body.toString("utf8") : body instanceof Uint8Array ? Buffer.from(body).toString("utf8") : "");
|
|
944
1031
|
if (typeof globalThis.fetch === "function") {
|
|
945
1032
|
const realFetch = globalThis.fetch;
|
|
1033
|
+
// A call to a Qdrant collection, sent again through this same wrapper at the copy's address, so
|
|
1034
|
+
// it is recorded like any retrieval; held when no copy could be made.
|
|
1035
|
+
const guardedFetch = async (self, wrapped, input, init, u, method, g) => {
|
|
1036
|
+
if (g.hold) return heldFetch(realFetch, input, init);
|
|
1037
|
+
const req = new Request(input, init);
|
|
1038
|
+
const clone = await (typeof g.copy === "string" ? g.copy : copyOf(u, (name) => req.headers.get(name) || "", g));
|
|
1039
|
+
if (!clone) return heldFetch(realFetch, input, init);
|
|
1040
|
+
const body = method === "GET" || method === "HEAD" ? undefined : await req.arrayBuffer();
|
|
1041
|
+
return wrapped.call(self, toCopy(u, clone, g.call).href, { method, headers: req.headers, body, signal: req.signal });
|
|
1042
|
+
};
|
|
946
1043
|
globalThis.fetch = function (input, init) {
|
|
947
1044
|
let url = null;
|
|
948
1045
|
let hold = false;
|
|
1046
|
+
let guard = null;
|
|
1047
|
+
let at = null;
|
|
1048
|
+
let method = "GET";
|
|
949
1049
|
try {
|
|
950
1050
|
const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
|
|
1051
|
+
at = u;
|
|
951
1052
|
if (isModelCall(u.host, u.pathname)) url = u;
|
|
952
|
-
|
|
953
|
-
|
|
1053
|
+
method = String((init && init.method) || (input && input.method) || "GET").toUpperCase();
|
|
1054
|
+
guard = !url ? guarded(u, method) : null;
|
|
1055
|
+
hold = !url && !guard && heldHere(u, method, (name) => new Headers((init && init.headers) || (input && input.headers) || undefined).get(name));
|
|
954
1056
|
} catch { /* not a URL we can read */ }
|
|
1057
|
+
if (guard) return guardedFetch(this, globalThis.fetch, input, init, at, method, guard);
|
|
955
1058
|
if (hold) return heldFetch(realFetch, input, init);
|
|
956
1059
|
if (!url) {
|
|
957
1060
|
// Every outbound host, named by the setting that points at it when one does, and by the
|
package/lib/verbs.mjs
CHANGED
|
@@ -193,8 +193,8 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
193
193
|
return [logSaid(appFailed(d)), back].filter(Boolean).reduce(withBack, runText(withTested(d, id)));
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
-
// `npx cortad wait`: returns when the next run ends, started from the browser or by the
|
|
197
|
-
// what it found. A coding agent starts it in the background and is woken when it exits; it is a shell
|
|
196
|
+
// `npx cortad wait`: returns when the next run or verify ends, started from the browser or by the
|
|
197
|
+
// agent, with what it found. A coding agent starts it in the background and is woken when it exits; it is a shell
|
|
198
198
|
// verb alone, since an MCP call is cut at about 60 seconds. A run playing when it starts is that run,
|
|
199
199
|
// and so is a finished one the agent has not read: a first run can end before the agent starts this.
|
|
200
200
|
const wait = async () => {
|
|
@@ -211,10 +211,13 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
211
211
|
if ([401, 403, 404].includes(res.status)) return failed(res);
|
|
212
212
|
if (res.ok && id && finished(res.data)) {
|
|
213
213
|
const text = shown(res.data, id).replace(/\nnext: [^\n]*$/, "");
|
|
214
|
-
|
|
214
|
+
// A verify's verdict is read by run_status; a run's problems by findings.
|
|
215
|
+
return { text: `${text}\nnext: ${res.data.verify ? `run_status ${id}` : `findings ${res.data.jobId ?? id}`}`, data: res.data };
|
|
215
216
|
}
|
|
216
217
|
const r = res.ok && !id ? res.data?.run : null;
|
|
217
|
-
|
|
218
|
+
// A verify is followed like a run: the agent that started it is woken at its end instead of
|
|
219
|
+
// polling run_status twelve times a round.
|
|
220
|
+
if (r?.jobId && (!finished(r) || !wasRead(r.jobId))) { id = r.jobId; continue; }
|
|
218
221
|
// A run being followed holds on the server; a failed call, or no run yet, is asked again later.
|
|
219
222
|
if (!id || !res.ok) await sleep(WAIT_POLL_MS);
|
|
220
223
|
}
|
package/lib/wire.cjs
CHANGED
|
@@ -481,6 +481,30 @@ const passagesIn = (sent) => {
|
|
|
481
481
|
const RETRIEVAL_HOST = /pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai/i;
|
|
482
482
|
const RETRIEVAL_PATH = /\/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:\/|$)|\/points\/(?:search|query)|\/rpc\/match\w*/i;
|
|
483
483
|
const isRetrieval = (u) => RETRIEVAL_HOST.test(u.hostname) || RETRIEVAL_PATH.test(u.pathname);
|
|
484
|
+
// A call to one Qdrant collection, by Qdrant's own REST shape: /collections/{name}[/...]. `write`:
|
|
485
|
+
// the call changes the collection. Every PUT, PATCH and DELETE does; a POST does unless it is one
|
|
486
|
+
// of Qdrant's reads spelled as a POST (search, query, scroll, recommend, discover, count, facet, a
|
|
487
|
+
// retrieve of points by id at /points itself). A path under /collections that names no collection
|
|
488
|
+
// (/collections/aliases) is a write of the server, not of one collection: `collection` is null.
|
|
489
|
+
// `kind`, on a write: "points" changes what the collection holds (an upsert, a delete, a payload or
|
|
490
|
+
// vector change, a batch) and is what a copy stands in for; "create" makes the collection; anything
|
|
491
|
+
// else ("schema": a payload index, an alias, a snapshot, a shard, the collection's own settings or
|
|
492
|
+
// its deletion) changes the collection's shape and is held, never copied. ulaim creates a payload
|
|
493
|
+
// index on every one of its 300 collections at its first request, and copying each of them for
|
|
494
|
+
// that filled its cluster with copies and its searches with 503s.
|
|
495
|
+
const QDRANT_COLLECTION = /^\/collections\/([^/]+)(\/.*)?$/;
|
|
496
|
+
const QDRANT_READ_POST = /^\/points(?:\/(?:search|query|recommend|discover)(?:\/(?:batch|groups))?|\/scroll|\/count|\/facet|\/search\/matrix\/(?:pairs|offsets))?$/;
|
|
497
|
+
const QDRANT_POINTS = /^\/points(?:\/(?:delete|payload|payload\/delete|payload\/clear|vectors|vectors\/delete|batch))?(?:\?.*)?$/;
|
|
498
|
+
const qdrantCall = (u, method) => {
|
|
499
|
+
const m = QDRANT_COLLECTION.exec(u.pathname);
|
|
500
|
+
if (!m) return null;
|
|
501
|
+
const [, name, rest = ""] = m;
|
|
502
|
+
const collection = name === "aliases" ? null : decodeURIComponent(name);
|
|
503
|
+
const verb = String(method || "GET").toUpperCase();
|
|
504
|
+
const write = verb === "PUT" || verb === "PATCH" || verb === "DELETE" || (verb === "POST" && !QDRANT_READ_POST.test(rest));
|
|
505
|
+
const kind = !write ? null : rest === "" && verb === "PUT" ? "create" : QDRANT_POINTS.test(rest) && verb !== "DELETE" ? "points" : "schema";
|
|
506
|
+
return { collection, rest, write, kind };
|
|
507
|
+
};
|
|
484
508
|
const PASSAGE_KEY = /^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$/i;
|
|
485
509
|
const passagesFrom = (raw) => {
|
|
486
510
|
const out = [];
|
|
@@ -880,6 +904,6 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
|
|
|
880
904
|
|
|
881
905
|
module.exports = {
|
|
882
906
|
MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, MODEL_HOST,
|
|
883
|
-
scrubbed, list, clipped, argsText, turnOf, toolText, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames,
|
|
907
|
+
scrubbed, list, clipped, argsText, turnOf, toolText, isModelCall, isRetrieval, qdrantCall, passagesFrom, decoded, cookieNames,
|
|
884
908
|
fitted, makeRecorder, asChat, GATEWAY_MODEL,
|
|
885
909
|
};
|
package/local.mjs
CHANGED
|
@@ -134,10 +134,11 @@ async function holdProject() {
|
|
|
134
134
|
// A verb waits on this record from the moment it starts this process, so it is taken first.
|
|
135
135
|
if (viaToken) await holdProject();
|
|
136
136
|
|
|
137
|
-
// ---- what leaves the machine
|
|
137
|
+
// ---- what leaves the machine
|
|
138
138
|
const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode|secrets?|\.secrets?)$/i;
|
|
139
139
|
// Env files by any name (scripts/books.env is one), keys, credentials, media and compiled output.
|
|
140
140
|
const SKIP_FILE = /^\.env(\..*)?$|^\.envrc$|\.env$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|numbers|DS_Store)$/i;
|
|
141
|
+
const EVALS = /(?:^|\/)(?:evals?|evaluations?|benchmarks?|gold(?:ens?)?|red[-_]?team)\/|(?:^|[\/._-])(?:evals?|evaluations?|gold(?:en)?|benchmarks?|fidelity|red[-_]?team|cases)(?:[._-][^\/]*)?$/i;
|
|
141
142
|
const MAX_FILE = 25_000_000;
|
|
142
143
|
const MAX_TOTAL = 256_000_000;
|
|
143
144
|
const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
|
|
@@ -178,7 +179,7 @@ function gitListed() {
|
|
|
178
179
|
function shareable(rel, askGit = true) {
|
|
179
180
|
const parts = rel.split(sep);
|
|
180
181
|
if (parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) || SKIP_FILE.test(parts.at(-1))) return false;
|
|
181
|
-
if (listed === null || listed.has(rel)) return true;
|
|
182
|
+
if (listed === null || listed.has(rel) || EVALS.test(parts.join("/"))) return true;
|
|
182
183
|
// Off the list git gave a moment ago: ignored, or inside a nested checkout. The walk takes git's
|
|
183
184
|
// word for it; asking per file was one process each, two minutes on a repository with a worktree
|
|
184
185
|
// inside it. A read asked later, of a file made after the list was taken (an agent edit), asks.
|
|
@@ -947,6 +948,7 @@ const keeping = makeKeeping({
|
|
|
947
948
|
root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
|
|
948
949
|
launch: (plan) => sidecar(plan.cmd, plan.cwd),
|
|
949
950
|
connections: () => capture?.connections() ?? [],
|
|
951
|
+
copies: () => capture?.copies() ?? [],
|
|
950
952
|
settings: () => {
|
|
951
953
|
const from = {};
|
|
952
954
|
for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
|
|
@@ -1262,7 +1264,8 @@ const firstStart = startApp();
|
|
|
1262
1264
|
firstStart.catch(() => { /* appLife reads the failure */ });
|
|
1263
1265
|
const archive = join(work, "tree.tgz");
|
|
1264
1266
|
step("connecting");
|
|
1265
|
-
|
|
1267
|
+
// COPYFILE_DISABLE: a Mac's tar adds no "._name" entries.
|
|
1268
|
+
await exec("tar", ["-czf", archive, "-C", root, "-T", list], { env: { ...process.env, COPYFILE_DISABLE: "1" } });
|
|
1266
1269
|
const bytes = readFileSync(archive);
|
|
1267
1270
|
const PART = 1_500_000;
|
|
1268
1271
|
let resumed = false;
|
|
@@ -1516,9 +1519,12 @@ const autoReach = makeAutoReach({
|
|
|
1516
1519
|
stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
|
|
1517
1520
|
asked: () => existsSync(join(homeOf(project), ASK)),
|
|
1518
1521
|
report: async (answers) => {
|
|
1519
|
-
if (!answers.length && reachedBox === box) return;
|
|
1522
|
+
if (!answers.length && reachedBox === box) return true;
|
|
1520
1523
|
const to = box;
|
|
1521
|
-
|
|
1524
|
+
// False where the server did not take it: the answers are kept and said again (lib/auto-reach.mjs).
|
|
1525
|
+
const ok = Boolean((await call("POST", `/local/${to}/reached`, { answers }, { timeoutMs: 15_000 }).catch(() => null))?.ok);
|
|
1526
|
+
if (ok) reachedBox = to;
|
|
1527
|
+
return ok;
|
|
1522
1528
|
},
|
|
1523
1529
|
say,
|
|
1524
1530
|
fetchImpl: countedFetch,
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -34,22 +34,23 @@ The connect command prints "Go back to the browser" once it is connected. Cortad
|
|
|
34
34
|
- each `For the person:` line, as printed.
|
|
35
35
|
2. When `status` lists an endpoint as `Needs a signed-in user`, Cortad could not make a sign-in for it on this machine. Tell the person; when they ask for it, sign in a test account through the app's own sign-in on this machine, send that endpoint one request in that session, then call `status` again. That is the only sign-in this needs, and only for the endpoints listed that way.
|
|
36
36
|
3. A failure on the app's side that every conversation would repeat, such as a model provider refusing, a retrieval with no answer or a tool returning an error: say what you would change, change it when the person agrees, then call `reach` to send Cortad's test request to that endpoint again.
|
|
37
|
-
4. The first run starts
|
|
37
|
+
4. The first run starts when the person presses Run on the card, or asks you, and then you call `run`. Before you stop, start `npx -y {{cortad}} wait` as a background command (in Claude Code: Bash with run_in_background). It returns when the next run or verify ends, with what it found, and its ending wakes you. Then follow "After a run".
|
|
38
38
|
|
|
39
39
|
`status` with `show` lists one section in full: `rules`, `standards`, `journeys`, `endpoints`, `trials`, `records` or `reviews` (in a shell, `npx {{cortad}} status rules`). `status` with show `machine` says what Cortad does on this machine; hand the person its `For the person:` lines when they ask.
|
|
40
40
|
|
|
41
41
|
## After a run
|
|
42
42
|
|
|
43
|
-
The
|
|
43
|
+
The run is Cortad's; the code is the person's. You report first, and you change nothing until they say so.
|
|
44
44
|
|
|
45
|
-
1. Tell the person in
|
|
46
|
-
2.
|
|
47
|
-
3.
|
|
48
|
-
4.
|
|
45
|
+
1. Call `findings`. Tell the person, in plain lines: how many conversations played and how many completed their request, with the rate and range as printed; each problem worth their time, worst first, with its file and line and what you would change there in one line; which findings read their app wrong (see "A check that reads wrong"); and which problems need a decision only they can make.
|
|
46
|
+
2. Then stop, and let them answer. Until their go, nothing is edited, no verify starts and no run starts. They choose which problems to fix, in what order, and whether the change you propose fits their product.
|
|
47
|
+
3. On their go, one problem at a time: one change, in the file and near the line the finding names, then `verify <findingId>`. Its first line says how many conversations the round replays and what that counts against this month; start `npx -y {{cortad}} wait` in the background, and it wakes you when the verify ends, so `run_status` is called once at most.
|
|
48
|
+
4. Tell them what the verify decided, in its own words; the next change comes after their next go. A verify that cannot tell offers another round in its last line; it plays only when you call `verify` again, and only when the person wants it.
|
|
49
|
+
5. When they stop, say what changed, what each verify decided, and what is still open, then start `npx -y {{cortad}} wait` in the background again.
|
|
49
50
|
|
|
50
51
|
## Following a run
|
|
51
52
|
|
|
52
|
-
`run` answers within a second; when the app is still starting it says so, and `run_status` holds until the run has an id. `run_status` follows the run this machine started last, or the latest run. Each call holds up to 45 seconds, returns as soon as the count moves, and ends with the next call;
|
|
53
|
+
`run` answers within a second; when the app is still starting it says so, and `run_status` holds until the run has an id. `run_status` follows the run this machine started last, or the latest run. Each call holds up to 45 seconds, returns as soon as the count moves, and ends with the next call; `npx -y {{cortad}} wait` in the background does the following for you and wakes you at the end of a run or a verify. Hand the person its `For the person:` line at most once every two minutes. When the plan is spent, `run` says so and nothing runs.
|
|
53
54
|
|
|
54
55
|
## The first run: the baseline
|
|
55
56
|
|
|
@@ -65,8 +66,8 @@ The first finished run on a repository prints, in order: what ran; the simulated
|
|
|
65
66
|
|
|
66
67
|
## Fixing one finding
|
|
67
68
|
|
|
68
|
-
1. One change, in the file and near the line the finding names.
|
|
69
|
-
2. `verify <findingId>` replays the conversations the finding failed on, word for word, against the saved edit; Cortad starts the app again first when its code changed. Undecided after a round, it
|
|
69
|
+
1. One change, in the file and near the line the finding names, after the person's go.
|
|
70
|
+
2. `verify <findingId>` replays the conversations the finding failed on, word for word, against the saved edit; Cortad starts the app again first when its code changed. Its first line says how many conversations the round replays and what they count against. Undecided after a round, it says what another round would play; that round starts only when `verify` is called again. A `crash:N` finding replays the requests that were out when the app stopped.
|
|
70
71
|
3. Read the verdict. Replies the app refused or failed on the replay come first.
|
|
71
72
|
- Worse on the conversations saved to test the fix: undo the change, and aim the next one at the behavior the question asks about.
|
|
72
73
|
- Gone on the saved conversations while its own still fail: keep the fix; a reply that changed is another problem to fix next.
|
|
@@ -98,7 +98,7 @@ Rules in the code: 16, in 5 files.
|
|
|
98
98
|
Sends Cortad's test request again, from the connect command on this machine, after a fix:
|
|
99
99
|
|
|
100
100
|
```
|
|
101
|
-
Sent 1 request to your app on port 3100,
|
|
101
|
+
Sent 1 request to your app on port 3100, up to four at a time:
|
|
102
102
|
POST /api/homework/explain: 200 in 2.4 seconds.
|
|
103
103
|
status shows which of them reached your model.
|
|
104
104
|
next: status
|