cascrow-sdk 0.1.0 → 0.4.0
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/CHANGELOG.md +10 -0
- package/README.md +31 -10
- package/dist/client.d.ts +280 -27
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +609 -60
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +158 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -4
package/dist/client.js
CHANGED
|
@@ -1,17 +1,216 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Client deadlines, mirrored from `src/lib/latency-budget.ts` (SEC-073).
|
|
3
|
+
*
|
|
4
|
+
* A published package cannot import from the app, so these are copies by
|
|
5
|
+
* necessity. `standalone-client-deadline.test.ts` is what keeps every copy in the
|
|
6
|
+
* tree equal — a number typed in FOUR places (this file, `cascrow-mcp`,
|
|
7
|
+
* `cascrow-cli`, and the owner in `src/lib/latency-budget.ts`) is four numbers
|
|
8
|
+
* that agree today.
|
|
9
|
+
*/
|
|
10
|
+
/** `/api/verify` streams under a 240 s function cap; one step below its kill. */
|
|
11
|
+
const CLIENT_ABORT_MS = 235_000;
|
|
12
|
+
/** `/api/verify/standalone` is capped at 120 s. */
|
|
13
|
+
const STANDALONE_CLIENT_ABORT_MS = 115_000;
|
|
14
|
+
/**
|
|
15
|
+
* The default for every other call. The longest `maxDuration` any route in this
|
|
16
|
+
* API declares is 240 s, so this cannot cut a legitimately slow on-chain call
|
|
17
|
+
* short — it only bounds a connection nobody is on the other end of.
|
|
18
|
+
*/
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 240_000;
|
|
20
|
+
/**
|
|
21
|
+
* `/api/proof/upload`: its 30 s function cap plus a transfer allowance for a
|
|
22
|
+
* 20 MB body. Mirrors `UPLOAD_CLIENT_ABORT_MS` in src/lib/latency-budget.ts;
|
|
23
|
+
* `client-deadline-mirrors.test.ts` keeps the two equal. `submitProof` had NO
|
|
24
|
+
* deadline before T11.
|
|
25
|
+
*/
|
|
26
|
+
const UPLOAD_CLIENT_ABORT_MS = 120_000;
|
|
27
|
+
/**
|
|
28
|
+
* An API error a caller can branch on (T11-A2): the HTTP status, the stable `code`
|
|
29
|
+
* (see the API's error codes — plus `UPSTREAM_NON_JSON`, which this client sets when
|
|
30
|
+
* the server answered with something that is not JSON), and how long the server
|
|
31
|
+
* asked us to wait (`Retry-After`, else `retry_after_seconds`). Additive: `status`,
|
|
32
|
+
* `body` and the message are what they always were.
|
|
33
|
+
*/
|
|
34
|
+
function makeApiError(status, body, headers) {
|
|
35
|
+
const b = (typeof body === "object" && body !== null ? body : {});
|
|
36
|
+
const message = typeof b.error === "string" ? b.error : `HTTP ${status}`;
|
|
5
37
|
const err = new Error(message);
|
|
6
38
|
err.status = status;
|
|
7
39
|
err.body = body;
|
|
40
|
+
err.code = typeof b.code === "string" ? b.code : null;
|
|
41
|
+
const header = headers?.get("retry-after") ?? null;
|
|
42
|
+
err.retryAfterSeconds =
|
|
43
|
+
header !== null && /^\d+$/.test(header.trim())
|
|
44
|
+
? Number(header.trim())
|
|
45
|
+
: typeof b.retry_after_seconds === "number"
|
|
46
|
+
? b.retry_after_seconds
|
|
47
|
+
: null;
|
|
48
|
+
// The lost-claim 409 (VERIFICATION_IN_PROGRESS) says whether a run is going and
|
|
49
|
+
// where to watch it — `waitForProof(proofId)` is the call to make then.
|
|
50
|
+
if (typeof b.phase === "string")
|
|
51
|
+
err.phase = b.phase;
|
|
52
|
+
if (typeof b.watch_url === "string")
|
|
53
|
+
err.watchUrl = b.watch_url;
|
|
54
|
+
if (typeof b.expected_done_in_seconds === "number")
|
|
55
|
+
err.expectedDoneInSeconds = b.expected_done_in_seconds;
|
|
8
56
|
return err;
|
|
9
57
|
}
|
|
58
|
+
/** A body parsed without trusting it to be JSON: an HTML 502 is "HTTP 502 (non-JSON response from server)". */
|
|
59
|
+
async function readJsonSafe(res) {
|
|
60
|
+
const text = await res.text();
|
|
61
|
+
if (!text)
|
|
62
|
+
return {};
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** True when `value` is the non-JSON marker `readJsonSafe` produced. */
|
|
71
|
+
function isNonJson(value) {
|
|
72
|
+
return typeof value === "object" && value !== null && value.code === "UPSTREAM_NON_JSON";
|
|
73
|
+
}
|
|
74
|
+
// ─── The bar a milestone is judged against ────────────────────────────────────
|
|
75
|
+
// COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
|
|
76
|
+
// (a published package cannot import the app). milestone-bar-input-parity.test.ts runs
|
|
77
|
+
// this copy against the owner over the same case matrix.
|
|
78
|
+
const CHECKLIST_MAX_ITEMS = 20;
|
|
79
|
+
const CHECKLIST_TITLE_MAX = 200;
|
|
80
|
+
const CHECKLIST_ID_MAX = 100;
|
|
81
|
+
const CRITERIA_MAX = 4000;
|
|
82
|
+
const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
83
|
+
function normalizeMilestoneChecklist(items) {
|
|
84
|
+
if (items === undefined || items === null)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!Array.isArray(items))
|
|
87
|
+
throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
|
|
88
|
+
if (items.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (items.length > CHECKLIST_MAX_ITEMS)
|
|
91
|
+
throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
|
|
92
|
+
items.forEach((raw, i) => {
|
|
93
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
94
|
+
throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
const withId = items.filter((it) => it.id !== undefined).length;
|
|
98
|
+
if (withId !== 0 && withId !== items.length) {
|
|
99
|
+
throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
|
|
100
|
+
}
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
return items.map((raw, i) => {
|
|
103
|
+
const it = raw;
|
|
104
|
+
const title = typeof it.title === "string" ? it.title.trim() : "";
|
|
105
|
+
if (title.length === 0)
|
|
106
|
+
throw new Error(`verificationChecklist[${i}]: title is required`);
|
|
107
|
+
if (title.length > CHECKLIST_TITLE_MAX)
|
|
108
|
+
throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
|
|
109
|
+
let id = i + 1;
|
|
110
|
+
if (withId) {
|
|
111
|
+
if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
|
|
112
|
+
throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
|
|
113
|
+
}
|
|
114
|
+
if (seen.has(it.id))
|
|
115
|
+
throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
|
|
116
|
+
seen.add(it.id);
|
|
117
|
+
id = it.id;
|
|
118
|
+
}
|
|
119
|
+
const out = { id, title };
|
|
120
|
+
if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
|
|
121
|
+
const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
|
|
122
|
+
if (!CHECKLIST_SEVERITIES.includes(sev)) {
|
|
123
|
+
throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
|
|
124
|
+
}
|
|
125
|
+
out.severity = sev;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function normalizeCriteria(value) {
|
|
131
|
+
if (value === undefined || value === null)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (typeof value !== "string")
|
|
134
|
+
throw new Error("verificationCriteria: must be a string");
|
|
135
|
+
const v = value.trim();
|
|
136
|
+
if (v.length === 0)
|
|
137
|
+
return undefined;
|
|
138
|
+
if (v.length > CRITERIA_MAX)
|
|
139
|
+
throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
10
142
|
/**
|
|
11
143
|
* Typed client for the Cascrow API. Wraps the same REST endpoints used by
|
|
12
144
|
* the cascrow-mcp server and the Cascrow web app — this is the plain-code
|
|
13
145
|
* equivalent for applications that don't go through an MCP/LLM tool call.
|
|
14
146
|
*/
|
|
147
|
+
/** Every `action` the verification stream can send. Kept next to the check so the
|
|
148
|
+
* two cannot drift: adding a value to `VerificationResult["action"]` without adding
|
|
149
|
+
* it here makes this file fail to compile. */
|
|
150
|
+
const STREAM_ACTIONS = ["VERIFIED", "PENDING_REVIEW", "REJECTED", "COMPLETED", "SUPERSEDED"];
|
|
151
|
+
/**
|
|
152
|
+
* The stream's `action`, or a thrown error — never an invented verdict.
|
|
153
|
+
*
|
|
154
|
+
* SEC-059. This used to be `(msg.action as VerificationResult["action"]) ?? "REJECTED"`,
|
|
155
|
+
* twice. Two things were wrong with it. The cast accepted `COMPLETED` and `SUPERSEDED`
|
|
156
|
+
* — real values the stream sends — while the declared union listed neither, so the
|
|
157
|
+
* returned object was outside its own type and nothing said so. And the fallback
|
|
158
|
+
* invented `REJECTED` for an absent field: a missing `action` is a protocol violation,
|
|
159
|
+
* not a rejection, and defaulting it in the one direction that costs the builder their
|
|
160
|
+
* reputation is the worst available choice. Every `complete` frame the route sends
|
|
161
|
+
* carries an action, so this cannot fire against a healthy server; when it does fire,
|
|
162
|
+
* it throws the way a malformed frame already does rather than guessing.
|
|
163
|
+
*/
|
|
164
|
+
function requireAction(value) {
|
|
165
|
+
if (typeof value === "string" && STREAM_ACTIONS.includes(value)) {
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`Verification stream sent an action this client does not know: ${JSON.stringify(value)}. ` +
|
|
169
|
+
`Expected one of ${STREAM_ACTIONS.join(", ")} — upgrade cascrow-sdk.`);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The terminal status of the row a proof settles against, or null while it is open.
|
|
173
|
+
* `milestoneClosed` is derived from the row by `/api/agent/proof-status`;
|
|
174
|
+
* `milestoneStatus` is the fallback for a server that predates the field.
|
|
175
|
+
*/
|
|
176
|
+
function closedMilestoneStatus(s) {
|
|
177
|
+
if (s.milestoneClosed === true)
|
|
178
|
+
return s.terminalStatus || s.milestoneStatus || s.contractStatus || "EXPIRED";
|
|
179
|
+
// A legacy contract-level proof has no milestone: an older server reports its row only
|
|
180
|
+
// as `contractStatus`, and reading `milestoneStatus` alone reported a proof whose
|
|
181
|
+
// contract had EXPIRED as open.
|
|
182
|
+
const row = s.milestoneStatus ?? s.contractStatus;
|
|
183
|
+
return row === "EXPIRED" || row === "COMPLETED" ? row : null;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* True when the milestone is approved (VERIFIED) or paid (COMPLETED) and this proof has
|
|
187
|
+
* no YES of its own: another proof won it, or the funder approved it in review — which
|
|
188
|
+
* leaves this proof's `aiDecision` untouched (NO, or null after a panel outage). Nothing
|
|
189
|
+
* is left for this proof to decide: `/api/verify`'s claim excludes VERIFIED and
|
|
190
|
+
* COMPLETED for every verdict, so a pending proof there never gets one, and the loop
|
|
191
|
+
* below used to burn its whole timeout on it. Same predicate as both MCP surfaces.
|
|
192
|
+
*/
|
|
193
|
+
function approvedWithoutThisVerdict(s) {
|
|
194
|
+
// Except while this proof's panel is RUNNING on a VERIFIED row. VERIFIED is not sticky:
|
|
195
|
+
// a definite release failure rolls it back, `contracts/review` hands it back to
|
|
196
|
+
// PENDING_REVIEW and the cron's hand-back blocks do the same, and a running claim then
|
|
197
|
+
// decides the row after all. COMPLETED is terminal and stays final either way.
|
|
198
|
+
// `running` is absent on a server that predates it, which keeps the old reading.
|
|
199
|
+
const row = closedMilestoneStatus(s) === "COMPLETED" ? "COMPLETED" : (s.milestoneStatus ?? s.contractStatus);
|
|
200
|
+
if (s.decision === "YES")
|
|
201
|
+
return false;
|
|
202
|
+
return row === "COMPLETED" || (row === "VERIFIED" && s.running !== true);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Parked for a human: no verdict, the milestone at `PENDING_REVIEW`, and no panel running
|
|
206
|
+
* on this proof. A RUNNING proof on a PENDING_REVIEW row is not parked — a sibling proof
|
|
207
|
+
* in the 60-85 band put the milestone there while this one's panel still runs, and this
|
|
208
|
+
* one can still take it to COMPLETED. Same test as `parkedForHuman` at both MCP surfaces;
|
|
209
|
+
* without `running` (an older server) the old reading stays.
|
|
210
|
+
*/
|
|
211
|
+
function parkedForHuman(s) {
|
|
212
|
+
return Boolean(s.pending) && s.running !== true && s.milestoneStatus === "PENDING_REVIEW";
|
|
213
|
+
}
|
|
15
214
|
export class CascrowClient {
|
|
16
215
|
apiKey;
|
|
17
216
|
baseUrl;
|
|
@@ -22,19 +221,84 @@ export class CascrowClient {
|
|
|
22
221
|
authHeaders() {
|
|
23
222
|
return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
|
|
24
223
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Every non-streaming call carries a deadline (SEC-073).
|
|
226
|
+
*
|
|
227
|
+
* Before this, the only `AbortController` in the SDK was inside `verify()`, the
|
|
228
|
+
* streaming method — so `verifyWork()`, which waits on a route the platform
|
|
229
|
+
* kills at 120 s, waited forever and then surfaced a torn connection. The
|
|
230
|
+
* default is the longest cap any route in this API declares (240 s), so it
|
|
231
|
+
* cannot cut a legitimately slow on-chain call short; `verifyWork` passes the
|
|
232
|
+
* tighter one because its own route is capped lower.
|
|
233
|
+
*
|
|
234
|
+
* An `AbortError` is re-thrown as a sentence, not as the DOM error: a caller
|
|
235
|
+
* cannot tell "we stopped waiting" from "the panel said no" out of an
|
|
236
|
+
* exception whose message is "This operation was aborted" (F-72).
|
|
237
|
+
*/
|
|
238
|
+
async request(method, path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
239
|
+
const controller = new AbortController();
|
|
240
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
241
|
+
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
|
|
242
|
+
let res;
|
|
243
|
+
try {
|
|
244
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
245
|
+
method,
|
|
246
|
+
headers: {
|
|
247
|
+
...this.authHeaders(),
|
|
248
|
+
...(body !== undefined && !isForm ? { "Content-Type": "application/json" } : {}),
|
|
249
|
+
},
|
|
250
|
+
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
|
|
251
|
+
signal: controller.signal,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
256
|
+
throw new Error(`cascrow did not answer ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
257
|
+
"This client stopped waiting; the server may still be working on it.");
|
|
258
|
+
}
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
// Cleared AFTER the body, not in a `finally` around the header fetch: a
|
|
263
|
+
// server that sends headers and then stalls the body was otherwise
|
|
264
|
+
// unbounded, and the promise below is what the caller actually waits on.
|
|
265
|
+
// The streaming path in this same file learned this already — its abort
|
|
266
|
+
// "only became load-bearing when the clearTimeout moved below the read loop".
|
|
267
|
+
try {
|
|
268
|
+
let data;
|
|
269
|
+
try {
|
|
270
|
+
data = await readJsonSafe(res);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
// The deadline can fire during the body read too; say so the same way as
|
|
274
|
+
// at the header fetch, with the code a caller branches on.
|
|
275
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
276
|
+
const e = new Error(`cascrow did not finish answering ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
277
|
+
"This client stopped waiting; the server may still be working on it.");
|
|
278
|
+
e.status = res.status;
|
|
279
|
+
e.body = null;
|
|
280
|
+
e.code = "CLIENT_TIMEOUT";
|
|
281
|
+
e.retryAfterSeconds = null;
|
|
282
|
+
throw e;
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
// A 200 that is not JSON is not a success anyone can read.
|
|
287
|
+
if (!res.ok || isNonJson(data)) {
|
|
288
|
+
const err = makeApiError(res.status, data, res.headers);
|
|
289
|
+
// A platform error page on a POST (the 504 when a route hits its own
|
|
290
|
+
// maxDuration — escrow-fund's is 120 s) does not mean the call failed: the
|
|
291
|
+
// function may have moved money before it was killed.
|
|
292
|
+
if (method !== "GET" && res.status >= 500 && isNonJson(data)) {
|
|
293
|
+
err.message += ". The request may still have completed on the server — check the contract (getContract) before repeating it.";
|
|
294
|
+
}
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
return data;
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
}
|
|
38
302
|
}
|
|
39
303
|
// ─── Auth ───────────────────────────────────────────────────────────────────
|
|
40
304
|
/** Self-register an agent account. No CAPTCHA, no email verification. Returns an API key — save it, it is shown only once. */
|
|
@@ -57,11 +321,27 @@ export class CascrowClient {
|
|
|
57
321
|
return d.toISOString();
|
|
58
322
|
};
|
|
59
323
|
const data = await this.request("POST", "/api/contracts", {
|
|
60
|
-
milestones: input.milestones.map((m) =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
324
|
+
milestones: input.milestones.map((m, i) => {
|
|
325
|
+
// T07. Normalised here so a bad item is refused with its milestone and
|
|
326
|
+
// item index, not as a 400 on the whole contract. Nothing given → the
|
|
327
|
+
// body is what it always was.
|
|
328
|
+
let criteria;
|
|
329
|
+
let checklist;
|
|
330
|
+
try {
|
|
331
|
+
criteria = normalizeCriteria(m.verificationCriteria);
|
|
332
|
+
checklist = normalizeMilestoneChecklist(m.verificationChecklist);
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
throw new Error(`milestones[${i}]: ${e instanceof Error ? e.message : String(e)}`);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
title: m.title,
|
|
339
|
+
amountUSD: m.amountUSD ?? 0,
|
|
340
|
+
cancelAfter: cancelAfter(m.daysUntilDeadline),
|
|
341
|
+
...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
|
|
342
|
+
...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
|
|
343
|
+
};
|
|
344
|
+
}),
|
|
65
345
|
agentReviewMode: input.agentReviewMode,
|
|
66
346
|
});
|
|
67
347
|
return {
|
|
@@ -83,9 +363,52 @@ export class CascrowClient {
|
|
|
83
363
|
async joinContract(inviteCode) {
|
|
84
364
|
return this.request("POST", "/api/contracts/join", { inviteCode });
|
|
85
365
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Approve or reject a borderline (PENDING_REVIEW) MILESTONE. Only valid when
|
|
368
|
+
* agentReviewMode is MANUAL or MANUAL_AUTO.
|
|
369
|
+
*
|
|
370
|
+
* The CONTRACT row need not be PENDING_REVIEW: funding a later milestone
|
|
371
|
+
* overwrites that column, and such a contract is still reviewable. Only a
|
|
372
|
+
* terminal contract (COMPLETED, EXPIRED, DECLINED) is refused.
|
|
373
|
+
*
|
|
374
|
+
* `milestoneId` is optional while exactly one milestone of the contract is
|
|
375
|
+
* pending review and REQUIRED once more than one is — the API answers 400
|
|
376
|
+
* "Multiple milestones are pending review — specify milestoneId to
|
|
377
|
+
* disambiguate". Until this parameter existed an SDK caller had no way to
|
|
378
|
+
* comply with that refusal, which is exactly the case SEC-076 is about: a
|
|
379
|
+
* multi-milestone contract where a second proof lands while the first is
|
|
380
|
+
* still under review (SEC-120, F-72).
|
|
381
|
+
*
|
|
382
|
+
* Additive optional third argument — every existing call site still compiles.
|
|
383
|
+
*/
|
|
384
|
+
async reviewContract(contractId, decision, milestoneId) {
|
|
385
|
+
return this.request("POST", "/api/contracts/review", milestoneId ? { contractId, decision, milestoneId } : { contractId, decision });
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The contracts on which THIS agent, as funder, still owes a manual verdict.
|
|
389
|
+
*
|
|
390
|
+
* The pull behind the `manual_review.required` webhook. A webhook that does
|
|
391
|
+
* not arrive — the agent was down, the endpoint 500'd, the subscription was
|
|
392
|
+
* never made — is a decision that never gets made, and `cron/cancel-expired`
|
|
393
|
+
* then auto-approves the milestone after 48 h (agent contracts) or 14 days
|
|
394
|
+
* (human) and releases the escrow. The whole point of the 60–85 confidence
|
|
395
|
+
* band is that somebody looks.
|
|
396
|
+
*
|
|
397
|
+
* Every browser surface the funder has for this asks
|
|
398
|
+
* `funderReviewQueueWhere()`; so does this route, so the queue an agent polls
|
|
399
|
+
* and the queue `reviewContract` accepts a verdict on cannot come apart.
|
|
400
|
+
*
|
|
401
|
+
* `reviewMilestoneId` is what to pass to {@link reviewContract}: non-null when
|
|
402
|
+
* exactly one milestone of that contract is pending, `null` for the legacy
|
|
403
|
+
* contract-level population that is reviewed by `contractId` alone. When
|
|
404
|
+
* `pendingMilestones` holds more than one entry the milestone has to be chosen
|
|
405
|
+
* — `POST /api/contracts/review` answers 400 otherwise.
|
|
406
|
+
*
|
|
407
|
+
* `truncated` says the list is capped while `total` is not, so a poller does
|
|
408
|
+
* not read a short list as "that was all".
|
|
409
|
+
*/
|
|
410
|
+
async reviewQueue() {
|
|
411
|
+
return this.request("GET", "/api/agent/review-queue");
|
|
89
412
|
}
|
|
90
413
|
/** Check for pending contract invites sent to this agent. */
|
|
91
414
|
async checkInvites() {
|
|
@@ -100,25 +423,42 @@ export class CascrowClient {
|
|
|
100
423
|
async fundMilestone(contractId, milestoneId) {
|
|
101
424
|
return this.request("POST", "/api/agent/fund-milestone", milestoneId ? { contractId, milestoneId } : { contractId });
|
|
102
425
|
}
|
|
103
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* Fund a milestone with real on-chain RLUSD escrow using the agent's own EVM
|
|
428
|
+
* private key.
|
|
429
|
+
*
|
|
430
|
+
* **The escrowed amount is the milestone's own `amountUSD`.** The route
|
|
431
|
+
* destructures `{ contractId, milestoneId, agentPrivateKey }` and funds
|
|
432
|
+
* `milestone.amountUSD`; a body `amountUSD` has never been read.
|
|
433
|
+
*
|
|
434
|
+
* SEC-120 removed the parameter from both MCP tool schemas, the OpenAPI spec
|
|
435
|
+
* and the CLI — and stopped here, at the one surface where the demand was
|
|
436
|
+
* enforced by the COMPILER rather than merely written down: `amountUSD:
|
|
437
|
+
* number`, no `?`, and `input` forwarded whole. That is the same defect the
|
|
438
|
+
* removal was about, at the door that states it most strongly, and it
|
|
439
|
+
* survived because the correction was checked against the places that
|
|
440
|
+
* document the parameter rather than against the places that require it.
|
|
441
|
+
*
|
|
442
|
+
* It is deprecated rather than deleted: deleting it would break compilation
|
|
443
|
+
* for every existing caller, which is a worse answer than telling them the
|
|
444
|
+
* truth. It is no longer forwarded, so the request body now says what the
|
|
445
|
+
* server actually reads.
|
|
446
|
+
*/
|
|
104
447
|
async escrowFund(input) {
|
|
105
|
-
|
|
448
|
+
const { contractId, agentPrivateKey, milestoneId } = input;
|
|
449
|
+
return this.request("POST", "/api/agent/escrow-fund", { contractId, agentPrivateKey, milestoneId });
|
|
106
450
|
}
|
|
107
451
|
// ─── Proof & verification ───────────────────────────────────────────────────
|
|
108
|
-
/**
|
|
452
|
+
/**
|
|
453
|
+
* Submit a text proof report for a milestone. Verification starts by itself —
|
|
454
|
+
* wait for the verdict with `waitForProof(proofId)`; do not call `verify()` for it,
|
|
455
|
+
* that run is already going.
|
|
456
|
+
*/
|
|
109
457
|
async submitProof(input) {
|
|
110
458
|
const form = new FormData();
|
|
111
459
|
form.append("milestoneId", input.milestoneId);
|
|
112
460
|
form.append("file", new Blob([input.content], { type: "text/plain" }), input.filename ?? "proof-report.txt");
|
|
113
|
-
|
|
114
|
-
method: "POST",
|
|
115
|
-
headers: this.authHeaders(),
|
|
116
|
-
body: form,
|
|
117
|
-
});
|
|
118
|
-
const data = await res.json().catch(() => ({}));
|
|
119
|
-
if (!res.ok)
|
|
120
|
-
throw makeApiError(res.status, data);
|
|
121
|
-
return data;
|
|
461
|
+
return this.request("POST", "/api/proof/upload", form, UPLOAD_CLIENT_ABORT_MS);
|
|
122
462
|
}
|
|
123
463
|
/** Check the AI verification result for a submitted proof (poll after submitProof). */
|
|
124
464
|
async getProofStatus(proofId) {
|
|
@@ -126,11 +466,21 @@ export class CascrowClient {
|
|
|
126
466
|
}
|
|
127
467
|
/**
|
|
128
468
|
* Stream live 5-model AI verification results for a submitted proof.
|
|
129
|
-
*
|
|
469
|
+
*
|
|
470
|
+
* By default resolves once the final result arrives, i.e. after the on-chain
|
|
471
|
+
* release, so `txHash` is populated. Pass `{ resolveOn: "decision" }` to
|
|
472
|
+
* resolve as soon as the AI verdict is known instead — typically several
|
|
473
|
+
* seconds earlier, since the release waits for a sidechain block. `txHash` is
|
|
474
|
+
* then null and the release is still in flight; use a webhook or
|
|
475
|
+
* `getProofStatus` if you need to observe it landing.
|
|
130
476
|
*/
|
|
131
|
-
async verify(proofId) {
|
|
477
|
+
async verify(proofId, options = {}) {
|
|
478
|
+
const resolveOn = options.resolveOn ?? "complete";
|
|
132
479
|
const controller = new AbortController();
|
|
133
|
-
|
|
480
|
+
// SEC-073: 235_000, and it only became load-bearing when the clearTimeout
|
|
481
|
+
// moved below the read loop — see src/lib/latency-budget.ts:CLIENT_ABORT_MS.
|
|
482
|
+
// Kept as a numeric literal because this package cannot import from src/.
|
|
483
|
+
const timeout = setTimeout(() => controller.abort(), CLIENT_ABORT_MS);
|
|
134
484
|
let res;
|
|
135
485
|
try {
|
|
136
486
|
res = await fetch(`${this.baseUrl}/api/verify`, {
|
|
@@ -140,12 +490,28 @@ export class CascrowClient {
|
|
|
140
490
|
signal: controller.signal,
|
|
141
491
|
});
|
|
142
492
|
}
|
|
143
|
-
|
|
493
|
+
catch (e) {
|
|
494
|
+
clearTimeout(timeout);
|
|
495
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
496
|
+
throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
|
|
497
|
+
}
|
|
498
|
+
throw e;
|
|
499
|
+
}
|
|
500
|
+
if (!res.ok) {
|
|
144
501
|
clearTimeout(timeout);
|
|
502
|
+
// A 409 with code VERIFICATION_IN_PROGRESS and phase "running" means a run is
|
|
503
|
+
// already going (usually the one upload started): the error carries `phase`,
|
|
504
|
+
// `watchUrl` and `expectedDoneInSeconds` — call `waitForProof(proofId)`. Its
|
|
505
|
+
// `retryAfterSeconds` is the ~1 h takeover cutoff, NOT a backoff.
|
|
506
|
+
throw makeApiError(res.status, await readJsonSafe(res), res.headers);
|
|
145
507
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
508
|
+
// Split from the line above, which was `!res.ok || !res.body`. A 200 with no
|
|
509
|
+
// body then produced `makeApiError(200, {})` — an "API error" carrying a
|
|
510
|
+
// success status, and the one door of six without the separate branch the
|
|
511
|
+
// other five have.
|
|
512
|
+
if (!res.body) {
|
|
513
|
+
clearTimeout(timeout);
|
|
514
|
+
throw new Error("Verification stream had no body");
|
|
149
515
|
}
|
|
150
516
|
const votes = [];
|
|
151
517
|
const reader = res.body.getReader();
|
|
@@ -175,14 +541,48 @@ export class CascrowClient {
|
|
|
175
541
|
if (msg.type === "model_vote") {
|
|
176
542
|
votes.push(msg);
|
|
177
543
|
}
|
|
544
|
+
else if (msg.type === "decision" && resolveOn === "decision") {
|
|
545
|
+
return {
|
|
546
|
+
decision: msg.decision ?? "NO",
|
|
547
|
+
confidence: msg.confidence ?? 0,
|
|
548
|
+
action: requireAction(msg.action),
|
|
549
|
+
reasoning: msg.reasoning ?? "",
|
|
550
|
+
modelVotes: votes,
|
|
551
|
+
txHash: null,
|
|
552
|
+
};
|
|
553
|
+
// `reasoning` here is MODEL OUTPUT over evidence the counterparty
|
|
554
|
+
// submitted, and it reaches you RAW. The MCP servers prefix it with an
|
|
555
|
+
// explicit untrusted-content marker; this SDK does not, deliberately —
|
|
556
|
+
// its return value may equally end up in a UI, where a marker is noise
|
|
557
|
+
// on a page, and a library cannot know which.
|
|
558
|
+
//
|
|
559
|
+
// That asymmetry was justified in the app for a while with a sentence
|
|
560
|
+
// that is FALSE of this file: "an agent reaching the SSE frames does so
|
|
561
|
+
// through an MCP server that marks at its own end". A review pass found
|
|
562
|
+
// this door — `verify()`/`verifyAndWait` hand the field to a
|
|
563
|
+
// TypeScript agent with no MCP server in the path. The decision is
|
|
564
|
+
// unchanged; the reason is now stated where it is true. If you feed this
|
|
565
|
+
// into a model, treat it as data and not as instructions.
|
|
566
|
+
}
|
|
178
567
|
else if (msg.type === "complete") {
|
|
179
568
|
return {
|
|
180
569
|
decision: msg.decision ?? "NO",
|
|
181
570
|
confidence: msg.confidence ?? 0,
|
|
182
|
-
action: msg.action
|
|
571
|
+
action: requireAction(msg.action),
|
|
183
572
|
reasoning: msg.reasoning ?? "",
|
|
184
573
|
modelVotes: votes,
|
|
185
574
|
txHash: msg.txHash ?? null,
|
|
575
|
+
// Forwarded because `action` alone does not answer "did the money move":
|
|
576
|
+
// a VERIFIED with a hold or a compliance block did not, and a COMPLETED
|
|
577
|
+
// that someone else released did. SEC-058 / SEC-059.
|
|
578
|
+
...(msg.alreadyReleased === true ? { alreadyReleased: true } : {}),
|
|
579
|
+
...(typeof msg.releaseHoldUntil === "string" ? { releaseHoldUntil: msg.releaseHoldUntil } : {}),
|
|
580
|
+
...(msg.complianceBlocked === true ? { complianceBlocked: true } : {}),
|
|
581
|
+
...(msg.timedOut === true ? { timedOut: true } : {}),
|
|
582
|
+
// With `action: "SUPERSEDED"`: the milestone was closed (e.g. EXPIRED)
|
|
583
|
+
// before this verification could settle it, rather than decided by
|
|
584
|
+
// another proof.
|
|
585
|
+
...(typeof msg.settledAs === "string" ? { settledAs: msg.settledAs } : {}),
|
|
186
586
|
};
|
|
187
587
|
}
|
|
188
588
|
else if (msg.type === "error") {
|
|
@@ -190,8 +590,26 @@ export class CascrowClient {
|
|
|
190
590
|
}
|
|
191
591
|
}
|
|
192
592
|
}
|
|
593
|
+
// SEC-073. The abort timer is cleared HERE, after the read loop — not in a
|
|
594
|
+
// `finally` around the `fetch`. `fetch` resolves on response HEADERS, about
|
|
595
|
+
// 12 s into `/api/verify`, and the entire 205 s retry ladder arrives
|
|
596
|
+
// afterwards out of `res.body`. Clearing it at the fetch made this a
|
|
597
|
+
// time-to-first-byte deadline over a twelve-second phase, so the number
|
|
598
|
+
// bounded nothing that mattered and could sit below the server's worst
|
|
599
|
+
// case unnoticed.
|
|
600
|
+
}
|
|
601
|
+
catch (e) {
|
|
602
|
+
// SEC-073. Live deadline, so this can fire mid-read; and per
|
|
603
|
+
// latency-budget.ts the abort can land after releaseMilestone(), which
|
|
604
|
+
// makes a bare "aborted" not merely unhelpful but possibly false about
|
|
605
|
+
// whether the money moved.
|
|
606
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
607
|
+
throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
|
|
608
|
+
}
|
|
609
|
+
throw e;
|
|
193
610
|
}
|
|
194
611
|
finally {
|
|
612
|
+
clearTimeout(timeout);
|
|
195
613
|
reader.cancel().catch(() => { });
|
|
196
614
|
}
|
|
197
615
|
throw new Error("Verification stream ended without a complete event");
|
|
@@ -212,48 +630,179 @@ export class CascrowClient {
|
|
|
212
630
|
custom_fields: input.evidence.customFields,
|
|
213
631
|
},
|
|
214
632
|
});
|
|
633
|
+
// Ordered so the two verdict-less responses are classified before the field that
|
|
634
|
+
// is missing on both is read. A `?? "rejected"` here would be the same
|
|
635
|
+
// wrong-direction default `requireAction` above was written to remove.
|
|
636
|
+
const verdict = data.decided === "elsewhere"
|
|
637
|
+
? "decided_elsewhere"
|
|
638
|
+
: data.panelUnavailable
|
|
639
|
+
? "panel_unavailable"
|
|
640
|
+
: data.verdict;
|
|
641
|
+
if (!verdict) {
|
|
642
|
+
throw new Error("mcp/submit answered 200 with no verdict and no known refusal state — upgrade cascrow-sdk.");
|
|
643
|
+
}
|
|
215
644
|
return {
|
|
216
|
-
verdict
|
|
217
|
-
confidence: data.confidence,
|
|
218
|
-
reasoning: data.reasoning,
|
|
219
|
-
onChainUrl: data.on_chain_url,
|
|
220
|
-
proofId: data.proof_id,
|
|
645
|
+
verdict,
|
|
646
|
+
confidence: data.confidence ?? null,
|
|
647
|
+
reasoning: data.reasoning ?? "",
|
|
648
|
+
onChainUrl: data.on_chain_url ?? null,
|
|
649
|
+
proofId: data.proof_id ?? null,
|
|
650
|
+
releaseHoldUntil: data.release_hold_until ?? null,
|
|
651
|
+
settledAs: verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null,
|
|
221
652
|
};
|
|
222
653
|
}
|
|
223
654
|
/**
|
|
224
655
|
* Verify AI-generated work with no escrow contract — a task description
|
|
225
|
-
* plus a GitHub PR URL or pasted code. Works without an API key
|
|
226
|
-
*
|
|
656
|
+
* plus a GitHub PR URL or pasted code. Works without an API key: 3 free
|
|
657
|
+
* verifications per IP per 24h. An API key attributes the result to your
|
|
658
|
+
* account but does not raise that allowance today — there is no paid tier
|
|
659
|
+
* yet, so do not treat a key as a way to get more runs.
|
|
227
660
|
*/
|
|
228
661
|
async verifyWork(input) {
|
|
229
|
-
return this.request("POST", "/api/verify/standalone", input);
|
|
662
|
+
return this.request("POST", "/api/verify/standalone", input, STANDALONE_CLIENT_ABORT_MS);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* True when a thrown `CascrowApiError` means "cascrow could not run the panel",
|
|
666
|
+
* not "the work was not verified" (SEC-128).
|
|
667
|
+
*
|
|
668
|
+
* `verifyWork` resolves ONLY when a verdict exists. When fewer than three of the
|
|
669
|
+
* five models answer, the endpoint answers 503 with `panelUnavailable: true` and
|
|
670
|
+
* carries NO decision and NO confidence, so `request` throws — deliberately, and
|
|
671
|
+
* that is the whole repair: the previous behaviour was a 200 carrying
|
|
672
|
+
* `decision: "NO", confidence: 65`, which every consumer rendered as a rejection
|
|
673
|
+
* of the submitted work. An outage is not a verdict, and a retry is the right
|
|
674
|
+
* next move rather than a resubmission with "stronger" evidence.
|
|
675
|
+
*
|
|
676
|
+
* A thrown 500 from the same endpoint is NOT this: it reports `false` here,
|
|
677
|
+
* because "the models did not answer" is a claim about the panel and a crash is
|
|
678
|
+
* not evidence for it.
|
|
679
|
+
*/
|
|
680
|
+
static isPanelUnavailable(err) {
|
|
681
|
+
const body = err?.body;
|
|
682
|
+
return typeof body === "object" && body !== null && body.panelUnavailable === true;
|
|
230
683
|
}
|
|
231
684
|
/**
|
|
232
685
|
* Submit a proof and wait for AI verification to complete — combines
|
|
233
686
|
* submitProof + polling into a single call. Times out after ~100s and
|
|
234
687
|
* returns { pending: true, proofId } so you can poll manually.
|
|
688
|
+
*
|
|
689
|
+
* Returns early, with `milestoneClosed: true`, when the milestone is already
|
|
690
|
+
* terminal — including when no verdict was recorded, which on a closed milestone
|
|
691
|
+
* will never change. Check `milestoneClosed` before `decision`: a `"YES"` on a
|
|
692
|
+
* closed, `EXPIRED` milestone is the panel's opinion of the proof and moved no funds.
|
|
693
|
+
*
|
|
694
|
+
* Also returns early, with `approvedWithoutThisVerdict: true`, when the milestone is
|
|
695
|
+
* VERIFIED or COMPLETED without a YES from this proof (another proof won it, or the
|
|
696
|
+
* funder approved it in review): `decision` is then `"NO"` or `null`, and the
|
|
697
|
+
* milestone's outcome is NOT this proof's.
|
|
698
|
+
*
|
|
699
|
+
* A proof parked for a human (no verdict, milestone at `PENDING_REVIEW`) returns
|
|
700
|
+
* `{ pending: true, proofId, awaitingHumanReview: true }` on the first poll instead of
|
|
701
|
+
* after the full timeout: no poll will change it, the funder decides.
|
|
702
|
+
*
|
|
703
|
+
* Neither early return is taken while the server reports `running: true` on a
|
|
704
|
+
* VERIFIED or PENDING_REVIEW milestone: this proof's panel is still running and can
|
|
705
|
+
* still decide the row, so the loop keeps polling. A server that predates `running`
|
|
706
|
+
* cannot say so, and there the early returns stay as described.
|
|
235
707
|
*/
|
|
236
708
|
async watchVerification(input) {
|
|
237
709
|
const { proofId } = await this.submitProof(input);
|
|
238
|
-
|
|
239
|
-
|
|
710
|
+
return this.waitForProof(proofId);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Wait for the verification of a proof that is already submitted — the call to
|
|
714
|
+
* make after `submitProof`, and after `verify()` threw a 409 VERIFICATION_IN_PROGRESS
|
|
715
|
+
* with `phase: "running"` (T01-7). Same results as `watchVerification`, which is
|
|
716
|
+
* this plus the upload.
|
|
717
|
+
*
|
|
718
|
+
* Stops early, by throwing, when waiting cannot help: 401 / 403 (the key or the
|
|
719
|
+
* account does not change by waiting) and 404 (permanent — upload answers only
|
|
720
|
+
* after the proof exists and proof-status reads the primary database, so a 404
|
|
721
|
+
* means deleted or erased). Returns `{ pending: true, interrupted: true }` when the
|
|
722
|
+
* run that held the proof died (`claimPhase: "orphaned"`). On 429 / 503 it waits at
|
|
723
|
+
* least the server's Retry-After, never past `timeoutMs`; network errors and other
|
|
724
|
+
* 5xx are transient and it polls again.
|
|
725
|
+
*/
|
|
726
|
+
async waitForProof(proofId, options = {}) {
|
|
727
|
+
const MAX_WAIT_MS = options.timeoutMs ?? 100_000;
|
|
728
|
+
const POLL_MS = options.intervalMs ?? 5_000;
|
|
240
729
|
const started = Date.now();
|
|
730
|
+
let waitMs = POLL_MS;
|
|
241
731
|
while (Date.now() - started < MAX_WAIT_MS) {
|
|
242
|
-
await new Promise((r) => setTimeout(r,
|
|
243
|
-
|
|
244
|
-
|
|
732
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
733
|
+
waitMs = POLL_MS;
|
|
734
|
+
let status = null;
|
|
735
|
+
try {
|
|
736
|
+
status = await this.getProofStatus(proofId);
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
const err = e;
|
|
740
|
+
if (err.status === 401 || err.status === 403 || err.status === 404)
|
|
741
|
+
throw e;
|
|
742
|
+
if ((err.status === 429 || err.status === 503) && typeof err.retryAfterSeconds === "number") {
|
|
743
|
+
waitMs = Math.min(Math.max(POLL_MS, err.retryAfterSeconds * 1000), Math.max(0, MAX_WAIT_MS - (Date.now() - started)));
|
|
744
|
+
}
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
// A closed milestone ends the wait whether or not a verdict landed: an outage
|
|
748
|
+
// that lost to EXPIRED leaves `pending: true` for good, and the loop used to burn
|
|
749
|
+
// its full timeout on it. `milestoneStatus` is the fallback for a server that
|
|
750
|
+
// predates `milestoneClosed`.
|
|
751
|
+
const closed = status ? closedMilestoneStatus(status) : null;
|
|
752
|
+
// Approved or paid without a YES from this proof: nothing is left for it to
|
|
753
|
+
// decide, whether or not it has a verdict of its own.
|
|
754
|
+
const elsewhere = status ? approvedWithoutThisVerdict(status) : false;
|
|
755
|
+
if (status && (!status.pending || closed || elsewhere)) {
|
|
245
756
|
return {
|
|
246
757
|
pending: false,
|
|
247
|
-
|
|
758
|
+
// `?? "NO"` before: an ABSENT decision was reported as a rejection, the one
|
|
759
|
+
// direction that costs the builder their reputation. `null` says "the route
|
|
760
|
+
// reported none", which is the truth and forces the caller to look at
|
|
761
|
+
// `milestoneStatus`.
|
|
762
|
+
decision: status.decision ?? null,
|
|
248
763
|
confidence: status.confidence ?? null,
|
|
764
|
+
// Both forwarded, because `decision` answers a question about the PROOF and
|
|
765
|
+
// these answer the one about the MILESTONE. Dropping them is how a scheduled,
|
|
766
|
+
// contestable, unpaid approval reached a caller as a completed payout
|
|
767
|
+
// (SEC-058, at the fourth door of three that were fixed).
|
|
768
|
+
milestoneStatus: status.milestoneStatus,
|
|
769
|
+
releaseHoldUntil: status.releaseHoldUntil ?? null,
|
|
770
|
+
milestoneClosed: closed !== null,
|
|
771
|
+
terminalStatus: closed,
|
|
772
|
+
terminalReason: closed ? (status.terminalReason ?? null) : null,
|
|
773
|
+
approvedWithoutThisVerdict: elsewhere,
|
|
249
774
|
reasoning: status.reasoning,
|
|
250
775
|
};
|
|
251
776
|
}
|
|
777
|
+
// Parked for a human: `pending` stays true on this path until the funder decides,
|
|
778
|
+
// so waiting out the loop only delayed the same answer by 100 s. Same test as
|
|
779
|
+
// `parkedForHuman` at both MCP surfaces.
|
|
780
|
+
if (status && parkedForHuman(status)) {
|
|
781
|
+
return { pending: true, proofId, awaitingHumanReview: true };
|
|
782
|
+
}
|
|
783
|
+
// The run that held this proof died: nothing is running to wait for. verify()
|
|
784
|
+
// can restart it once its claim is an hour old; submitting again always works.
|
|
785
|
+
if (status && status.claimPhase === "orphaned") {
|
|
786
|
+
return { pending: true, proofId, interrupted: true };
|
|
787
|
+
}
|
|
252
788
|
}
|
|
253
789
|
return { pending: true, proofId };
|
|
254
790
|
}
|
|
255
791
|
// ─── Agent marketplace ──────────────────────────────────────────────────────
|
|
256
|
-
/**
|
|
792
|
+
/**
|
|
793
|
+
* Look up any agent's on-chain reputation by EVM wallet address. No auth required.
|
|
794
|
+
*
|
|
795
|
+
* `summary` reaches you RAW, and so do `name` and `skills` from `discoverAgents`
|
|
796
|
+
* below. The route deliberately does not mark them (the browser profile page renders
|
|
797
|
+
* the same fields) and both MCP servers do mark them — so this SDK is a machine
|
|
798
|
+
* boundary that does not, exactly like `verify()`.
|
|
799
|
+
*
|
|
800
|
+
* The DECISION is the same and so is its reason: a library cannot know whether its
|
|
801
|
+
* return value ends in a model's context or on a page, where a marker is noise.
|
|
802
|
+
* What was missing here was the REASON, stated at `verify()` and nowhere
|
|
803
|
+
* else, which made this door look like an oversight rather than a choice (SEC-120).
|
|
804
|
+
* If you feed these fields to a model, treat them as data and not as instructions.
|
|
805
|
+
*/
|
|
257
806
|
async getReputation(walletAddress) {
|
|
258
807
|
return this.request("GET", `/api/agent/reputation/${encodeURIComponent(walletAddress)}`);
|
|
259
808
|
}
|