cascrow-cli 1.2.2 → 1.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 +9 -0
- package/README.md +50 -6
- package/builder-agent.js +236 -46
- package/index.js +636 -55
- package/package.json +6 -3
package/index.js
CHANGED
|
@@ -19,27 +19,100 @@ function authHeader(key) {
|
|
|
19
19
|
return key ? { "Authorization": `Bearer ${key}` } : {};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
// Client deadlines (T11-A4). This package cannot import src/, so the one with an
|
|
23
|
+
// owner is MIRRORED and checked against it by client-deadline-mirrors.test.ts:
|
|
24
|
+
// UPLOAD_CLIENT_ABORT_MS mirrors UPLOAD_CLIENT_ABORT_MS in src/lib/latency-budget.ts
|
|
25
|
+
// mcp/submit is 235 s like the stream (one step below its 240 s cap); everything
|
|
26
|
+
// else gets the longest maxDuration any route has. None of these calls had a
|
|
27
|
+
// deadline before.
|
|
28
|
+
const UPLOAD_CLIENT_ABORT_MS = 120_000;
|
|
29
|
+
const MCP_SUBMIT_CLIENT_ABORT_MS = 235_000;
|
|
30
|
+
const REQUEST_CLIENT_ABORT_MS = 300_000;
|
|
31
|
+
|
|
32
|
+
/** A response body parsed without trusting it to be JSON: an HTML 502 reads "HTTP 502 (non-JSON response from server)". */
|
|
33
|
+
async function readJsonSafe(res) {
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
if (!text) return {};
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Seconds the server asked us to wait: Retry-After, else `retry_after_seconds`. */
|
|
44
|
+
function retryAfterOf(res, data) {
|
|
45
|
+
const header = res.headers && typeof res.headers.get === "function" ? res.headers.get("retry-after") : null;
|
|
46
|
+
if (header !== null && /^\d+$/.test(header.trim())) return Number(header.trim());
|
|
47
|
+
return typeof data?.retry_after_seconds === "number" ? data.retry_after_seconds : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The one way this CLI calls the API. Throws an Error carrying `status`, `code` and
|
|
52
|
+
* `retryAfterSeconds`; its message ends with the code in brackets, so a script that
|
|
53
|
+
* greps stderr can branch on it. The deadline covers the body read.
|
|
54
|
+
*/
|
|
55
|
+
async function fetchJson(method, path, { json, form, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS } = {}) {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
58
|
+
let res;
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
res = await fetch(`${BASE_URL}${path}`, {
|
|
62
|
+
method,
|
|
63
|
+
headers: { ...authHeader(key), ...(json !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
64
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : form !== undefined ? { body: form } : {}),
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
67
|
+
data = await readJsonSafe(res);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e && e.name === "AbortError") {
|
|
70
|
+
throw Object.assign(
|
|
71
|
+
new Error(`No answer from ${path} within ${Math.round(deadlineMs / 1000)} s. The request may still complete on the server — check its result before repeating it. [CLIENT_TIMEOUT]`),
|
|
72
|
+
{ status: 0, code: "CLIENT_TIMEOUT", retryAfterSeconds: null }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
throw e;
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
if (!res.ok || data.code === "UPSTREAM_NON_JSON") {
|
|
80
|
+
const code = typeof data.code === "string" ? data.code : null;
|
|
81
|
+
const retryAfterSeconds = retryAfterOf(res, data);
|
|
82
|
+
const base = typeof data.error === "string" && data.error ? data.error : `HTTP ${res.status}`;
|
|
83
|
+
// A platform error page on a POST (typically the 504 when a route hits its own
|
|
84
|
+
// maxDuration — escrow-fund's is 120 s) does not mean the call failed: the function
|
|
85
|
+
// may have moved money before it was killed. Say so, and how to check.
|
|
86
|
+
const mayHaveCompleted = method !== "GET" && res.status >= 500 && data.code === "UPSTREAM_NON_JSON";
|
|
87
|
+
throw Object.assign(
|
|
88
|
+
new Error(
|
|
89
|
+
`${base}${retryAfterSeconds !== null && (res.status === 429 || res.status === 503) ? ` — retry after ${retryAfterSeconds} s` : ""}${code ? ` [${code}]` : ""}` +
|
|
90
|
+
(mayHaveCompleted ? ". The request may still have completed on the server — check the contract (cascrow get --contract <id>) before repeating it." : "")
|
|
91
|
+
),
|
|
92
|
+
{ status: res.status, code, retryAfterSeconds }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
33
95
|
return data;
|
|
34
96
|
}
|
|
35
97
|
|
|
98
|
+
async function apiPost(path, body, key = API_KEY, deadlineMs = REQUEST_CLIENT_ABORT_MS) {
|
|
99
|
+
return fetchJson("POST", path, { json: body, key, deadlineMs });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// SEC-078. A failed standalone verification still has a row, and the route answers
|
|
103
|
+
// the 500 with `id`, `publicHash` and `reportUrl` so the caller who paid for it can name it. The
|
|
104
|
+
// CLI only prints a string, so the reference goes into the string. Both request
|
|
105
|
+
// shapes (multipart image, JSON) throw through here.
|
|
106
|
+
function standaloneFailure(status, data) {
|
|
107
|
+
const d = data ?? {};
|
|
108
|
+
const base = typeof d.error === "string" ? d.error : `HTTP ${status}`;
|
|
109
|
+
const report = typeof d.reportUrl === "string" ? d.reportUrl : typeof d.publicHash === "string" ? d.publicHash : null;
|
|
110
|
+
const ref = typeof d.id === "string" && report ? ` (verification id: ${d.id}, report: ${report})` : "";
|
|
111
|
+
return new Error(base + ref);
|
|
112
|
+
}
|
|
113
|
+
|
|
36
114
|
async function apiGet(path, key = API_KEY) {
|
|
37
|
-
|
|
38
|
-
headers: { ...authHeader(key) },
|
|
39
|
-
});
|
|
40
|
-
const data = await res.json();
|
|
41
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
42
|
-
return data;
|
|
115
|
+
return fetchJson("GET", path, { key });
|
|
43
116
|
}
|
|
44
117
|
|
|
45
118
|
async function uploadProof(milestoneId, filename, content, key = API_KEY) {
|
|
@@ -47,21 +120,17 @@ async function uploadProof(milestoneId, filename, content, key = API_KEY) {
|
|
|
47
120
|
const form = new FormData();
|
|
48
121
|
form.append("file", blob, filename);
|
|
49
122
|
form.append("milestoneId", milestoneId);
|
|
50
|
-
|
|
51
|
-
method: "POST",
|
|
52
|
-
headers: { ...authHeader(key) },
|
|
53
|
-
body: form,
|
|
54
|
-
});
|
|
55
|
-
const data = await res.json();
|
|
56
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
57
|
-
return data;
|
|
123
|
+
return fetchJson("POST", "/api/proof/upload", { form, key, deadlineMs: UPLOAD_CLIENT_ABORT_MS });
|
|
58
124
|
}
|
|
59
125
|
|
|
60
126
|
// Stream verify via SSE — used by the legacy two-step `submit` + `verify --proof <id>` flow
|
|
61
127
|
async function streamVerify(proofId, key = API_KEY) {
|
|
62
128
|
const controller = new AbortController();
|
|
63
|
-
//
|
|
64
|
-
|
|
129
|
+
// 235s: one step below the 240s maxDuration of /api/verify, not above its
|
|
130
|
+
// worst case — the tail reaches ~239s, so no value is both. See
|
|
131
|
+
// VERIFY_TAIL_WORST_CASE_MS in src/lib/latency-budget.ts. Cleared after the read loop, not at the fetch
|
|
132
|
+
// (SEC-073), which is what makes the number mean anything.
|
|
133
|
+
const timeout = setTimeout(() => controller.abort(), 235_000);
|
|
65
134
|
|
|
66
135
|
let res;
|
|
67
136
|
try {
|
|
@@ -75,13 +144,47 @@ async function streamVerify(proofId, key = API_KEY) {
|
|
|
75
144
|
body: JSON.stringify({ proofId }),
|
|
76
145
|
signal: controller.signal,
|
|
77
146
|
});
|
|
78
|
-
}
|
|
147
|
+
} catch (e) {
|
|
79
148
|
clearTimeout(timeout);
|
|
149
|
+
// SEC-073. Now that the deadline is live it can actually fire, and a raw
|
|
150
|
+
// `DOMException: This operation was aborted` names no remedy — worse, per
|
|
151
|
+
// the tail arithmetic in latency-budget.ts the abort can land AFTER
|
|
152
|
+
// releaseMilestone(), so "it failed" may be false about the money.
|
|
153
|
+
if (e && e.name === "AbortError") {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"Verification is still running on the server — this client stopped waiting, it did not fail. " +
|
|
156
|
+
`Poll GET /api/agent/proof-status/${proofId} for the verdict.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
throw e;
|
|
80
160
|
}
|
|
81
161
|
|
|
82
162
|
if (!res.ok) {
|
|
83
|
-
|
|
84
|
-
|
|
163
|
+
// SEC-073, second pass. Replacing the fetch's `finally` with a `catch` left
|
|
164
|
+
// THIS exit uncovered: a 409 from /api/verify (already verified, panel in
|
|
165
|
+
// progress, resubmit limit) returned with a 235 s timer still pending. In a
|
|
166
|
+
// long-lived MCP server that holds the event loop and later aborts a fetch
|
|
167
|
+
// that finished minutes ago.
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
const data = await readJsonSafe(res);
|
|
170
|
+
// T01-8. Upload starts verification by itself, so losing the claim to a RUNNING
|
|
171
|
+
// run is the normal case: wait for that run through proof-status instead of
|
|
172
|
+
// failing. This 409's Retry-After is the ~1 h takeover cutoff, never a wait.
|
|
173
|
+
if (res.status === 409 && data.code === "VERIFICATION_IN_PROGRESS" && data.phase === "running") {
|
|
174
|
+
process.stderr.write(" A verification of this proof is already running — waiting for it instead of starting another.\n");
|
|
175
|
+
const budgetMs = Math.max(30_000, Math.min(235_000, ((data.expected_done_in_seconds ?? 200) + 30) * 1000));
|
|
176
|
+
return { attached: true, status: await waitForProof(proofId, budgetMs, key) };
|
|
177
|
+
}
|
|
178
|
+
const code = typeof data.code === "string" ? data.code : null;
|
|
179
|
+
throw Object.assign(new Error(`${data.error ?? `HTTP ${res.status}`}${code ? ` [${code}]` : ""}`), {
|
|
180
|
+
status: res.status,
|
|
181
|
+
code,
|
|
182
|
+
retryAfterSeconds: retryAfterOf(res, data),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (!res.body) {
|
|
186
|
+
clearTimeout(timeout);
|
|
187
|
+
throw new Error("Verification stream had no body");
|
|
85
188
|
}
|
|
86
189
|
|
|
87
190
|
const votes = [];
|
|
@@ -117,15 +220,198 @@ async function streamVerify(proofId, key = API_KEY) {
|
|
|
117
220
|
}
|
|
118
221
|
}
|
|
119
222
|
}
|
|
223
|
+
// SEC-073. Cleared HERE, after the read loop — not in a `finally` around the
|
|
224
|
+
// `fetch`, which resolves on response HEADERS about 12 s into `/api/verify`
|
|
225
|
+
// while the whole 205 s retry ladder arrives afterwards out of `res.body`.
|
|
226
|
+
// At the fetch this was a time-to-first-byte deadline over a twelve-second
|
|
227
|
+
// phase, so its value bounded nothing and could sit below the server's worst
|
|
228
|
+
// case unnoticed. CLIENT_ABORT_MS was raised to 235_000 in the same commit.
|
|
229
|
+
} catch (e) {
|
|
230
|
+
// The deadline can now fire DURING the read — that is the whole point of
|
|
231
|
+
// clearing it below the loop rather than at the fetch. Same translation as
|
|
232
|
+
// the fetch-side catch above.
|
|
233
|
+
if (e && e.name === "AbortError") {
|
|
234
|
+
throw new Error(
|
|
235
|
+
"Verification is still running on the server — this client stopped waiting, it did not fail. " +
|
|
236
|
+
`Poll GET /api/agent/proof-status/${proofId} for the verdict.`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
throw e;
|
|
120
240
|
} finally {
|
|
241
|
+
clearTimeout(timeout);
|
|
121
242
|
reader.cancel().catch(() => {});
|
|
122
243
|
}
|
|
123
244
|
|
|
124
245
|
throw new Error("Stream ended without a complete event");
|
|
125
246
|
}
|
|
126
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Poll proof-status until the proof settles, the run is found dead, or `maxMs` runs
|
|
250
|
+
* out. Returns the last status read, with `stoppedBecause` set when it stopped early:
|
|
251
|
+
* - 401 / 403: waiting does not change the key or the account;
|
|
252
|
+
* - 404: permanent — upload answers only after the proof exists and proof-status
|
|
253
|
+
* reads the primary database, so the proof was deleted or erased;
|
|
254
|
+
* - "interrupted": the run that held the proof died (claimPhase "orphaned").
|
|
255
|
+
* 429 / 503 wait at least Retry-After, never past the budget; anything else is
|
|
256
|
+
* transient. A milestone that is closed, or parked for a human, also settles it.
|
|
257
|
+
*/
|
|
258
|
+
async function waitForProof(proofId, maxMs = 100_000, key = API_KEY, pollMs = 5_000) {
|
|
259
|
+
const started = Date.now();
|
|
260
|
+
let waitMs = pollMs;
|
|
261
|
+
let last = null;
|
|
262
|
+
while (Date.now() - started < maxMs) {
|
|
263
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
264
|
+
waitMs = pollMs;
|
|
265
|
+
try {
|
|
266
|
+
last = await apiGet(`/api/agent/proof-status/${encodeURIComponent(proofId)}`, key);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (e && (e.status === 401 || e.status === 403 || e.status === 404)) {
|
|
269
|
+
return { pending: true, proofId, stoppedBecause: e.status, error: e.message };
|
|
270
|
+
}
|
|
271
|
+
if (e && (e.status === 429 || e.status === 503) && typeof e.retryAfterSeconds === "number") {
|
|
272
|
+
waitMs = Math.min(Math.max(pollMs, e.retryAfterSeconds * 1000), Math.max(0, maxMs - (Date.now() - started)));
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!last.pending || last.milestoneClosed === true) return { ...last, proofId };
|
|
277
|
+
// Parked for a human: no poll will change it (same rule as both MCP servers).
|
|
278
|
+
if (last.running !== true && last.milestoneStatus === "PENDING_REVIEW") return { ...last, proofId, awaitingHumanReview: true };
|
|
279
|
+
if (last.claimPhase === "orphaned") return { ...last, proofId, stoppedBecause: "interrupted" };
|
|
280
|
+
}
|
|
281
|
+
return { ...(last ?? {}), pending: true, proofId, timedOut: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One line for a proof-status result, saying whether funds moved (they did only at COMPLETED). */
|
|
285
|
+
function statusSummary(s) {
|
|
286
|
+
if (s.stoppedBecause === 404) return `⛔ Proof ${s.proofId} no longer exists (deleted or erased). Nothing will verify it.`;
|
|
287
|
+
if (s.stoppedBecause === 401 || s.stoppedBecause === 403) return `⛔ Stopped: ${s.error}. Waiting does not change this — check CASCROW_API_KEY and that this account is a party to the contract.`;
|
|
288
|
+
if (s.stoppedBecause === "interrupted") {
|
|
289
|
+
// Not "re-run automatically": a claim with no timestamp is never taken over, and
|
|
290
|
+
// the retry cron skips demo contracts and milestones past PROOF_SUBMITTED.
|
|
291
|
+
return `⚠️ The last verification run was interrupted; nothing is running now. Once its claim is an hour old, \`cascrow verify --proof ${s.proofId}\` can start it again — or submit the proof again, which always works.`;
|
|
292
|
+
}
|
|
293
|
+
if (s.awaitingHumanReview) return "⏳ PENDING_REVIEW — the panel reached no verdict; a human decides. Waiting will not change it.";
|
|
294
|
+
if (s.timedOut) return `⏳ Still verifying. Check again: cascrow status --proof ${s.proofId} --wait`;
|
|
295
|
+
const conf = typeof s.confidence === "number" ? ` (${s.confidence}% confidence)` : "";
|
|
296
|
+
// COMPLETED first: proof-status reports a paid milestone as `milestoneClosed` too
|
|
297
|
+
// (terminalStatus COMPLETED), and the CLOSED arm below would read a release as
|
|
298
|
+
// "moves no funds" — SEC-059's inversion (same order as proofOutcomeLine in both
|
|
299
|
+
// MCP servers). Whether it was THIS proof's YES is `decision`.
|
|
300
|
+
if (s.milestoneStatus === "COMPLETED" || s.terminalStatus === "COMPLETED") {
|
|
301
|
+
return s.decision === "YES" ? `✅ COMPLETED${conf} — funds released.` : "✅ COMPLETED — funds released, but not on this proof's verdict (another proof or the funder's review approved it).";
|
|
302
|
+
}
|
|
303
|
+
if (s.milestoneClosed === true) {
|
|
304
|
+
return s.terminalReason === "RELEASED_ON_CHAIN"
|
|
305
|
+
? "⛔ CLOSED (EXPIRED) — the chain shows the builder WAS paid, just not by this verification."
|
|
306
|
+
: `⛔ CLOSED — the milestone is ${s.terminalStatus ?? "closed"}; this verification moved no funds. Do not resubmit to it.`;
|
|
307
|
+
}
|
|
308
|
+
if (s.milestoneStatus === "VERIFIED" && s.releaseHoldUntil) return `⏸️ VERIFIED${conf} — payout scheduled for ${s.releaseHoldUntil}. NO FUNDS HAVE MOVED YET.`;
|
|
309
|
+
if (s.milestoneStatus === "VERIFIED") return `⚠️ VERIFIED${conf} — approved, no funds moved yet.`;
|
|
310
|
+
if (s.milestoneStatus === "PENDING_REVIEW") return `⏳ PENDING_REVIEW${conf} — manual review required.`;
|
|
311
|
+
if (s.decision === "NO") return `❌ REJECTED${conf}.`;
|
|
312
|
+
return `ℹ️ Decision: ${s.decision ?? "none"}${conf}, milestone ${s.milestoneStatus ?? "unknown"}.`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* One human-readable line for a verification `complete` frame.
|
|
317
|
+
*
|
|
318
|
+
* Every arm says whether FUNDS MOVED, because that is the one thing a caller cannot
|
|
319
|
+
* find out any other way. `action === "VERIFIED"` alone never means they did: a hold and
|
|
320
|
+
* a compliance block carry their own flag, a success arrives as `COMPLETED`, and a BARE
|
|
321
|
+
* `VERIFIED` is reachable only when the automatic release was attempted and FAILED.
|
|
322
|
+
*/
|
|
323
|
+
function outcomeLine(r) {
|
|
324
|
+
const c = r.confidence ?? 0;
|
|
325
|
+
if (r.action === "SUPERSEDED") {
|
|
326
|
+
// `settledAs`: the milestone was closed (e.g. EXPIRED) before this verification could
|
|
327
|
+
// settle it, not beaten by a proof.
|
|
328
|
+
return r.settledAs
|
|
329
|
+
? `ℹ️ SUPERSEDED — this milestone was closed (${r.settledAs}) before this verification could settle it, so it changed nothing and moved no funds. Do not resubmit to it.`
|
|
330
|
+
: "ℹ️ SUPERSEDED — another proof on this milestone was decided first, so this verification changed nothing. Your evidence was recorded.";
|
|
331
|
+
}
|
|
332
|
+
if (r.action === "COMPLETED") {
|
|
333
|
+
return r.alreadyReleased === true
|
|
334
|
+
? "✅ COMPLETED — this milestone was already released by another request. The funds are out; do not resubmit."
|
|
335
|
+
: `✅ COMPLETED (${c}% confidence) — funds released${r.txHash ? `, tx ${r.txHash}` : ""}.`;
|
|
336
|
+
}
|
|
337
|
+
if (r.complianceBlocked === true) {
|
|
338
|
+
return `⏸️ VERIFIED (${c}%) — payout on hold pending compliance review. No funds moved.`;
|
|
339
|
+
}
|
|
340
|
+
if (r.releaseHoldUntil) {
|
|
341
|
+
return `⏸️ VERIFIED (${c}% confidence) — approved, payout scheduled for ${r.releaseHoldUntil}. NO FUNDS HAVE MOVED YET: the funder can contest until then, and the date can move.`;
|
|
342
|
+
}
|
|
343
|
+
if (r.action === "VERIFIED") {
|
|
344
|
+
return `⚠️ VERIFIED (${c}% confidence) — the work was approved, but the automatic payout did NOT go through. No funds have moved; the release can be retried.`;
|
|
345
|
+
}
|
|
346
|
+
if (r.timedOut === true) {
|
|
347
|
+
return "⏳ PENDING_REVIEW — the verification panel did not answer (too few models responded). This is NOT a rejection and NOT a judgement of the work; a human decides.";
|
|
348
|
+
}
|
|
349
|
+
if (r.action === "PENDING_REVIEW") {
|
|
350
|
+
return `⏳ PENDING_REVIEW (${c}%) — manual review required.`;
|
|
351
|
+
}
|
|
352
|
+
if (r.action === "REJECTED") {
|
|
353
|
+
return `❌ REJECTED (${c}%) — resubmit with stronger proof.`;
|
|
354
|
+
}
|
|
355
|
+
return `⚠️ This verification finished with an outcome this CLI does not recognise (action: ${r.action}). NO conclusion was drawn — check the milestone status and upgrade cascrow-cli.`;
|
|
356
|
+
}
|
|
357
|
+
|
|
127
358
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
128
359
|
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One `verify/standalone` call under a client deadline (SEC-073).
|
|
363
|
+
*
|
|
364
|
+
* The route declares `maxDuration = 120`; without this the only signal a hung
|
|
365
|
+
* call gives is the platform tearing the connection down, which reaches the
|
|
366
|
+
* caller as a transport error with nothing in it to act on. One function rather
|
|
367
|
+
* than a copy per shape: the two fetches it wraps are twenty lines apart in the
|
|
368
|
+
* same handler, and a guard reading either in isolation would have reported the
|
|
369
|
+
* pair as protected (F-12/F-60).
|
|
370
|
+
*
|
|
371
|
+
* Mirrors `STANDALONE_CLIENT_ABORT_MS` in `src/lib/latency-budget.ts`. A
|
|
372
|
+
* published package cannot import from the app, so this is a copy by necessity;
|
|
373
|
+
* `standalone-client-deadline.test.ts` is what keeps the FOUR copies equal — this
|
|
374
|
+
* file, the other published package, the SDK, and the owner in
|
|
375
|
+
* `src/lib/latency-budget.ts`.
|
|
376
|
+
*/
|
|
377
|
+
const STANDALONE_CLIENT_ABORT_MS = 115_000;
|
|
378
|
+
|
|
379
|
+
async function withStandaloneDeadline(run) {
|
|
380
|
+
const controller = new AbortController();
|
|
381
|
+
const timer = setTimeout(() => controller.abort(), STANDALONE_CLIENT_ABORT_MS);
|
|
382
|
+
try {
|
|
383
|
+
return await run(controller.signal);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err && err.name === "AbortError") {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Verification did not answer within ${Math.round(STANDALONE_CLIENT_ABORT_MS / 1000)}s. ` +
|
|
388
|
+
"The server may still be working on it — retry, and if it keeps timing out the panel is degraded."
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
throw err;
|
|
392
|
+
} finally {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* MACHINE OUTPUT ON stdout, and that is the point of this comment.
|
|
399
|
+
*
|
|
400
|
+
* `SEC-116-INVENTORY` exempted this CLI with the reason "schreibt nach stdout/stderr
|
|
401
|
+
* für einen Menschen". That is true of `err()` below and of the human summaries — and
|
|
402
|
+
* FALSE of this function, which emits `JSON.stringify` for the "CI pipelines and
|
|
403
|
+
* subprocesses" the README names as the target. An exemption whose reason holds for
|
|
404
|
+
* one half of its own subject is F-70, and it is recorded here rather than left in a
|
|
405
|
+
* document nobody reads next to the code.
|
|
406
|
+
*
|
|
407
|
+
* No marker is added, and that is a decision rather than an omission: this CLI's JSON
|
|
408
|
+
* is a published output shape, and prefixing fields inside it would change what every
|
|
409
|
+
* existing parser reads. The honest statement instead — any free text in this output
|
|
410
|
+
* (`reasoning`, `summary`, per-model votes) is MODEL OUTPUT over evidence a
|
|
411
|
+
* counterparty submitted, and reaches you RAW. If you pipe it into a model, treat it
|
|
412
|
+
* as data and not as instructions; the marked surfaces are the two MCP servers and the
|
|
413
|
+
* `/api/agent/verify/*` routes.
|
|
414
|
+
*/
|
|
129
415
|
function print(obj) {
|
|
130
416
|
console.log(JSON.stringify(obj, null, 2));
|
|
131
417
|
}
|
|
@@ -149,11 +435,14 @@ Commands:
|
|
|
149
435
|
create Create a milestone contract
|
|
150
436
|
fund Activate a milestone (simulation — no on-chain tx)
|
|
151
437
|
escrow-fund Fund a milestone with real on-chain RLUSD escrow
|
|
152
|
-
submit Upload proof text (
|
|
438
|
+
submit Upload proof text — verification starts by itself (then: status --proof <id> --wait)
|
|
439
|
+
status Show (or --wait for) the verification result of a proof
|
|
153
440
|
get Get contract status and milestones
|
|
154
441
|
join Join a contract as Builder via invite code
|
|
155
442
|
handoff Send a contract invite to a Builder agent by Agent ID
|
|
156
443
|
check-invites Check pending contract invites for this agent
|
|
444
|
+
review-queue As funder: list milestones still waiting for your verdict
|
|
445
|
+
review As funder: approve or reject a milestone under manual review
|
|
157
446
|
me Print this agent's ID
|
|
158
447
|
|
|
159
448
|
Options for each command:
|
|
@@ -178,9 +467,13 @@ Options for each command:
|
|
|
178
467
|
--commit <sha> optional Git commit SHA or PR URL
|
|
179
468
|
|
|
180
469
|
create
|
|
181
|
-
--title <title> required milestone title
|
|
470
|
+
--title <title> required short milestone title (printed on the certificate)
|
|
182
471
|
--days <n> optional deadline days from now (default: 7)
|
|
183
472
|
--amount <usd> optional RLUSD to lock in escrow (default: 0 = verification-only)
|
|
473
|
+
--criteria <text> optional what the AI panel checks the proof against (max 4000 chars)
|
|
474
|
+
--criteria-file <path> optional the same, read from a file
|
|
475
|
+
--checklist-file <path> optional JSON array of { title, severity?, id? } (max 20) scored item by item on text proofs
|
|
476
|
+
--review-mode <mode> optional AUTO | MANUAL | MANUAL_AUTO — what happens when the panel is unsure (60–85%)
|
|
184
477
|
|
|
185
478
|
fund
|
|
186
479
|
--contract <id> required
|
|
@@ -189,14 +482,18 @@ Options for each command:
|
|
|
189
482
|
escrow-fund [REAL on-chain RLUSD — agent's own wallet]
|
|
190
483
|
--contract <id> required
|
|
191
484
|
--private-key <key> required EVM private key (funds come from this wallet)
|
|
192
|
-
--amount <usd>
|
|
485
|
+
--amount <usd> IGNORED the escrowed amount is the milestone's own amountUSD
|
|
193
486
|
--milestone <id> optional
|
|
194
487
|
|
|
195
|
-
submit [
|
|
488
|
+
submit [or use verify for text proof in one call]
|
|
196
489
|
--milestone <id> required
|
|
197
490
|
--proof <text> required
|
|
198
491
|
--file <name> optional filename (default: proof-report.txt)
|
|
199
492
|
|
|
493
|
+
status
|
|
494
|
+
--proof <id> required
|
|
495
|
+
--wait optional wait up to ~2 min for the verdict; stops early when waiting cannot help
|
|
496
|
+
|
|
200
497
|
get
|
|
201
498
|
--contract <id> required
|
|
202
499
|
|
|
@@ -226,7 +523,7 @@ Examples:
|
|
|
226
523
|
cascrow register --email bot@example.com --password secret123
|
|
227
524
|
|
|
228
525
|
# One-shot: create contract, fund it, then verify a code fix
|
|
229
|
-
cascrow create --title "Fix auth bug
|
|
526
|
+
cascrow create --title "Fix auth bug" --criteria "All tests pass; JWT expiry honoured; no new lint errors" --days 7
|
|
230
527
|
cascrow fund --contract <contractId>
|
|
231
528
|
cascrow verify --contract <contractId> --proof "Fixed JWT expiry bug in auth.ts, 42 tests green, PR #51 merged" --commit abc1234 --links https://github.com/you/repo/pull/51
|
|
232
529
|
|
|
@@ -242,10 +539,76 @@ Examples:
|
|
|
242
539
|
# (Builder agent runs:)
|
|
243
540
|
cascrow check-invites
|
|
244
541
|
cascrow join --invite <code>
|
|
542
|
+
|
|
543
|
+
# As the funder: decide what the AI panel could not (confidence 60-85)
|
|
544
|
+
# Poll this — a manual_review.required webhook you missed is a decision nobody
|
|
545
|
+
# makes, and cascrow auto-approves an undecided milestone after 48h.
|
|
546
|
+
cascrow review-queue
|
|
547
|
+
cascrow review --contract <contractId> --decision APPROVE
|
|
548
|
+
cascrow review --contract <contractId> --decision REJECT --milestone <milestoneId>
|
|
245
549
|
`);
|
|
246
550
|
process.exit(0);
|
|
247
551
|
}
|
|
248
552
|
|
|
553
|
+
// ─── The bar a milestone is judged against ────────────────────────────────────
|
|
554
|
+
// COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
|
|
555
|
+
// (this package cannot import src/). milestone-bar-input-parity.test.ts runs this copy
|
|
556
|
+
// against the owner over the same case matrix.
|
|
557
|
+
const CHECKLIST_MAX_ITEMS = 20;
|
|
558
|
+
const CHECKLIST_TITLE_MAX = 200;
|
|
559
|
+
const CHECKLIST_ID_MAX = 100;
|
|
560
|
+
const CRITERIA_MAX = 4000;
|
|
561
|
+
const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
562
|
+
|
|
563
|
+
function normalizeMilestoneChecklist(items) {
|
|
564
|
+
if (items === undefined || items === null) return undefined;
|
|
565
|
+
if (!Array.isArray(items)) throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
|
|
566
|
+
if (items.length === 0) return undefined;
|
|
567
|
+
if (items.length > CHECKLIST_MAX_ITEMS) throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
|
|
568
|
+
items.forEach((raw, i) => {
|
|
569
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
570
|
+
throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
const withId = items.filter((it) => it.id !== undefined).length;
|
|
574
|
+
if (withId !== 0 && withId !== items.length) {
|
|
575
|
+
throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
|
|
576
|
+
}
|
|
577
|
+
const seen = new Set();
|
|
578
|
+
return items.map((it, i) => {
|
|
579
|
+
const title = typeof it.title === "string" ? it.title.trim() : "";
|
|
580
|
+
if (title.length === 0) throw new Error(`verificationChecklist[${i}]: title is required`);
|
|
581
|
+
if (title.length > CHECKLIST_TITLE_MAX) throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
|
|
582
|
+
let id = i + 1;
|
|
583
|
+
if (withId) {
|
|
584
|
+
if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
|
|
585
|
+
throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
|
|
586
|
+
}
|
|
587
|
+
if (seen.has(it.id)) throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
|
|
588
|
+
seen.add(it.id);
|
|
589
|
+
id = it.id;
|
|
590
|
+
}
|
|
591
|
+
const out = { id, title };
|
|
592
|
+
if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
|
|
593
|
+
const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
|
|
594
|
+
if (!CHECKLIST_SEVERITIES.includes(sev)) {
|
|
595
|
+
throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
|
|
596
|
+
}
|
|
597
|
+
out.severity = sev;
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function normalizeCriteria(value) {
|
|
604
|
+
if (value === undefined || value === null) return undefined;
|
|
605
|
+
if (typeof value !== "string") throw new Error("verificationCriteria: must be a string");
|
|
606
|
+
const v = value.trim();
|
|
607
|
+
if (v.length === 0) return undefined;
|
|
608
|
+
if (v.length > CRITERIA_MAX) throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
|
|
609
|
+
return v;
|
|
610
|
+
}
|
|
611
|
+
|
|
249
612
|
// ─── Argument parser ──────────────────────────────────────────────────────────
|
|
250
613
|
|
|
251
614
|
function parseArgs(argv) {
|
|
@@ -282,8 +645,8 @@ async function cmdRegister(args) {
|
|
|
282
645
|
keyName: args["key-name"] ?? "cli",
|
|
283
646
|
}),
|
|
284
647
|
});
|
|
285
|
-
const data = await res
|
|
286
|
-
if (!res.ok) err(data.error ?? `HTTP ${res.status}`);
|
|
648
|
+
const data = await readJsonSafe(res);
|
|
649
|
+
if (!res.ok || data.code === "UPSTREAM_NON_JSON") err(`${data.error ?? `HTTP ${res.status}`}${data.code ? ` [${data.code}]` : ""}`);
|
|
287
650
|
print(data);
|
|
288
651
|
console.error("\nSave your API key and set it:\n export CASCROW_API_KEY=" + data.apiKey + "\n");
|
|
289
652
|
}
|
|
@@ -317,18 +680,46 @@ async function cmdVerifyWork(args) {
|
|
|
317
680
|
form.append("taskDescription", args.task);
|
|
318
681
|
form.append("image", new Blob([imageBuffer], { type: imageMimeType }), args.image.split("/").pop());
|
|
319
682
|
|
|
320
|
-
const res = await
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
683
|
+
const { res, data: body } = await withStandaloneDeadline(async (signal) => {
|
|
684
|
+
const r = await fetch(`${BASE_URL}/api/verify/standalone`, {
|
|
685
|
+
method: "POST",
|
|
686
|
+
headers: { ...authHeader(API_KEY) },
|
|
687
|
+
body: form,
|
|
688
|
+
signal,
|
|
689
|
+
});
|
|
690
|
+
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
691
|
+
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
692
|
+
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
693
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
324
694
|
});
|
|
325
|
-
|
|
326
|
-
|
|
695
|
+
if (!res.ok) throw standaloneFailure(res.status, body);
|
|
696
|
+
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
697
|
+
// branch left the function-scoped variable undefined and the next line read
|
|
698
|
+
// `data.decision` off it. `node --check` accepts a shadow happily.
|
|
699
|
+
data = body;
|
|
327
700
|
} else {
|
|
328
701
|
const body = { taskDescription: args.task };
|
|
329
702
|
if (args.pr) body.prUrl = args.pr;
|
|
330
703
|
if (args.code) body.codeText = args.code;
|
|
331
|
-
|
|
704
|
+
// Not `apiPost`: its generic throw drops the row reference the 500 carries.
|
|
705
|
+
const { res, data: answer } = await withStandaloneDeadline(async (signal) => {
|
|
706
|
+
const r = await fetch(`${BASE_URL}/api/verify/standalone`, {
|
|
707
|
+
method: "POST",
|
|
708
|
+
headers: { ...authHeader(API_KEY), "Content-Type": "application/json" },
|
|
709
|
+
body: JSON.stringify(body),
|
|
710
|
+
signal,
|
|
711
|
+
});
|
|
712
|
+
// The body is read INSIDE the deadline. A response whose headers arrived
|
|
713
|
+
// and whose body then stalls is otherwise unbounded — a deadline that ends
|
|
714
|
+
// at the headers bounds time-to-first-byte and nothing else (SEC-073).
|
|
715
|
+
return { res: r, data: await readJsonSafe(r) };
|
|
716
|
+
});
|
|
717
|
+
if (!res.ok) throw standaloneFailure(res.status, answer);
|
|
718
|
+
// Assigned to the OUTER `data`, not shadowing it: `const { data }` inside the
|
|
719
|
+
// branch left the function-scoped variable undefined and the next line read
|
|
720
|
+
// `data.decision` off it. `answer` rather than `body` because this branch
|
|
721
|
+
// already has a `body` — the REQUEST payload — twelve lines up.
|
|
722
|
+
data = answer;
|
|
332
723
|
}
|
|
333
724
|
|
|
334
725
|
const votes = Array.isArray(data.modelVotes) ? data.modelVotes : [];
|
|
@@ -359,25 +750,60 @@ async function cmdVerifyWork(args) {
|
|
|
359
750
|
// Auto-releases funds and mints NFT on approval — no separate `verify --proof <id>` needed.
|
|
360
751
|
async function cmdVerify(args) {
|
|
361
752
|
if (!API_KEY) err("CASCROW_API_KEY is not set");
|
|
362
|
-
if (!args.contract) err("--contract is required");
|
|
363
753
|
if (!args.proof) err("--proof is required");
|
|
364
754
|
|
|
365
|
-
// Legacy mode: --proof looks like a proof ID (prf_...) and no --contract — use stream verify
|
|
755
|
+
// Legacy mode: --proof looks like a proof ID (prf_...) and no --contract — use stream verify.
|
|
756
|
+
// Checked BEFORE the --contract requirement below: that check used to run
|
|
757
|
+
// first and process.exit(1) whenever --contract was missing, which made
|
|
758
|
+
// this branch (and streamVerify()) permanently unreachable — exactly the
|
|
759
|
+
// invocation cmdSubmit's own output tells users to run.
|
|
366
760
|
if (!args.contract && args.proof && !args.proof.includes(" ")) {
|
|
367
761
|
process.stderr.write("\nRunning AI verification (5 models)...\n\n");
|
|
368
762
|
const result = await streamVerify(args.proof);
|
|
369
|
-
|
|
763
|
+
// The run was already going (usually the one upload started): print its outcome
|
|
764
|
+
// as proof-status reports it — the stream's frame never arrived here.
|
|
765
|
+
if (result && result.attached) {
|
|
766
|
+
print(result.status);
|
|
767
|
+
process.stderr.write(statusSummary(result.status) + "\n");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
// SEC-055/058/059 at the third published surface. `passed` used to be
|
|
771
|
+
// `decision === "YES" && confidence >= 85`, i.e. a re-implementation of the
|
|
772
|
+
// three-tier rule from the PROOF's verdict — which answers a different question
|
|
773
|
+
// from the one a caller is asking. `COMPLETED`, `SUPERSEDED`, a compliance block
|
|
774
|
+
// and a release hold all rendered as verified, and a panel outage
|
|
775
|
+
// (`decision: "PENDING"`) rendered as "NOT VERIFIED 0%", i.e. our own outage read
|
|
776
|
+
// back to the builder as a rejection.
|
|
777
|
+
//
|
|
778
|
+
// `passed` now means MONEY MOVED, and only `COMPLETED` says that.
|
|
779
|
+
const released = result.action === "COMPLETED";
|
|
780
|
+
const passed = released;
|
|
370
781
|
print({
|
|
371
782
|
decision: result.decision,
|
|
372
783
|
confidence: result.confidence,
|
|
373
784
|
passed,
|
|
374
785
|
action: result.action,
|
|
786
|
+
released,
|
|
787
|
+
alreadyReleased: result.alreadyReleased === true,
|
|
788
|
+
superseded: result.action === "SUPERSEDED",
|
|
789
|
+
// With SUPERSEDED: the TERMINAL status the row was already in (EXPIRED, COMPLETED),
|
|
790
|
+
// or null when another proof won it. It used to reach only the stderr line from
|
|
791
|
+
// `outcomeLine`, so a machine caller reading this JSON could not tell "closed —
|
|
792
|
+
// nothing moved, do not resubmit" from "another proof won" (round-3 pass 2).
|
|
793
|
+
settledAs: typeof result.settledAs === "string" ? result.settledAs : null,
|
|
794
|
+
complianceBlocked: result.complianceBlocked === true,
|
|
795
|
+
releaseHoldUntil: result.releaseHoldUntil ?? null,
|
|
796
|
+
panelUnavailable: result.timedOut === true,
|
|
797
|
+
txHash: result.txHash ?? null,
|
|
375
798
|
reasoning: result.reasoning,
|
|
376
799
|
modelVotes: (result.votes ?? []).map(v => ({ model: v.model, decision: v.decision, confidence: v.confidence })),
|
|
377
800
|
});
|
|
801
|
+
process.stderr.write(outcomeLine(result) + "\n");
|
|
378
802
|
return;
|
|
379
803
|
}
|
|
380
804
|
|
|
805
|
+
if (!args.contract) err("--contract is required");
|
|
806
|
+
|
|
381
807
|
// One-shot mode: text proof → /api/mcp/submit → verdict + auto-release
|
|
382
808
|
const links = args.links
|
|
383
809
|
? args.links.split(",").map(l => l.trim()).filter(Boolean)
|
|
@@ -393,10 +819,20 @@ async function cmdVerify(args) {
|
|
|
393
819
|
contract_id: args.contract,
|
|
394
820
|
milestone_id: args.milestone ?? undefined,
|
|
395
821
|
evidence,
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
|
|
822
|
+
}, API_KEY, MCP_SUBMIT_CLIENT_ABORT_MS);
|
|
823
|
+
|
|
824
|
+
// `decided: "elsewhere"` and `panelUnavailable` are 200 responses that carry NO
|
|
825
|
+
// `verdict` key, so `data.verdict ?? "unknown"` fell to the final arm below and told
|
|
826
|
+
// the builder "❌ REJECTED — resubmit with stronger proof" for a milestone that may
|
|
827
|
+
// well have been approved and paid (SEC-055). Classified before the verdict is read.
|
|
828
|
+
const verdict = data.decided === "elsewhere"
|
|
829
|
+
? "decided_elsewhere"
|
|
830
|
+
: data.panelUnavailable
|
|
831
|
+
? "panel_unavailable"
|
|
832
|
+
: (data.verdict ?? "unknown");
|
|
399
833
|
const confidence = data.confidence ?? 0;
|
|
834
|
+
// Approved, payout SCHEDULED, funds still in escrow so the funder can contest.
|
|
835
|
+
const releaseHoldUntil = data.release_hold_until ?? null;
|
|
400
836
|
|
|
401
837
|
// Print model votes if available
|
|
402
838
|
const votes = Array.isArray(data.model_votes) ? data.model_votes : [];
|
|
@@ -406,21 +842,54 @@ async function cmdVerify(args) {
|
|
|
406
842
|
}
|
|
407
843
|
process.stderr.write("\n");
|
|
408
844
|
|
|
845
|
+
// With `decided: "elsewhere"`: the TERMINAL status the milestone was already in. EXPIRED
|
|
846
|
+
// is a funder cancel or the deadline, not "the funder's review, or a concurrent
|
|
847
|
+
// submission", and resubmitting to it is pointless.
|
|
848
|
+
const settledAs = verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null;
|
|
849
|
+
|
|
409
850
|
let statusLine;
|
|
410
|
-
if (
|
|
851
|
+
if (settledAs) {
|
|
852
|
+
statusLine =
|
|
853
|
+
`ℹ️ This milestone was closed (${settledAs}) before this submission could settle it, so this submission ` +
|
|
854
|
+
"changed nothing and moved no funds itself. Your evidence was recorded. Do not resubmit to this milestone";
|
|
855
|
+
} else if (verdict === "decided_elsewhere") {
|
|
856
|
+
statusLine =
|
|
857
|
+
"ℹ️ This milestone was decided by another request while your proof was being verified — " +
|
|
858
|
+
"by the funder's review, or by a concurrent submission. Your evidence was recorded. " +
|
|
859
|
+
"Check the milestone status before resubmitting";
|
|
860
|
+
} else if (verdict === "panel_unavailable") {
|
|
861
|
+
statusLine =
|
|
862
|
+
"⏳ PENDING_REVIEW — the verification panel could not reach a verdict (too few models " +
|
|
863
|
+
"answered). This is NOT a rejection and NOT a judgement of your work; a human decides, " +
|
|
864
|
+
"and resubmitting the same evidence will not help while the panel is down";
|
|
865
|
+
} else if (verdict === "approved" && releaseHoldUntil) {
|
|
866
|
+
statusLine =
|
|
867
|
+
`⏸️ APPROVED (${confidence}% confidence) — payout scheduled for ${releaseHoldUntil}. ` +
|
|
868
|
+
"NO FUNDS HAVE MOVED YET: the funder can contest until then, and the date can move";
|
|
869
|
+
} else if (verdict === "approved") {
|
|
411
870
|
statusLine = `✅ VERIFIED (${confidence}% confidence)`;
|
|
412
871
|
if (data.on_chain_url) statusLine += `\n Proof: ${data.on_chain_url}`;
|
|
413
872
|
} else if (verdict === "pending_review") {
|
|
414
873
|
statusLine = `⏳ PENDING_REVIEW (${confidence}% confidence) — manual review triggered`;
|
|
415
|
-
} else {
|
|
874
|
+
} else if (verdict === "rejected") {
|
|
416
875
|
statusLine = `❌ REJECTED (${confidence}% confidence) — resubmit with stronger proof`;
|
|
876
|
+
} else {
|
|
877
|
+
// Not a rejection: an outcome this version does not know. Drawing no conclusion is
|
|
878
|
+
// the one answer that cannot be wrong, and it makes the next value visible instead
|
|
879
|
+
// of silently a rejection.
|
|
880
|
+
statusLine =
|
|
881
|
+
`⚠️ This submission finished with an outcome this CLI does not recognise (verdict: ${verdict}). ` +
|
|
882
|
+
"No conclusion was drawn — check the milestone status and upgrade cascrow-cli";
|
|
417
883
|
}
|
|
418
884
|
process.stderr.write(statusLine + "\n\n");
|
|
419
885
|
|
|
420
886
|
print({
|
|
421
887
|
verdict,
|
|
422
888
|
confidence,
|
|
423
|
-
|
|
889
|
+
// MONEY MOVED — not "the verdict was approved". A scheduled payout has not happened.
|
|
890
|
+
passed: verdict === "approved" && !releaseHoldUntil,
|
|
891
|
+
release_hold_until: releaseHoldUntil,
|
|
892
|
+
...(settledAs ? { settled_as: settledAs } : {}),
|
|
424
893
|
reasoning: data.reasoning ?? "",
|
|
425
894
|
on_chain_url: data.on_chain_url ?? null,
|
|
426
895
|
proof_id: data.proof_id ?? null,
|
|
@@ -438,12 +907,40 @@ async function cmdCreate(args) {
|
|
|
438
907
|
deadline.setDate(deadline.getDate() + deadlineDays);
|
|
439
908
|
const amountUSD = parseFloat(args.amount ?? "0");
|
|
440
909
|
|
|
910
|
+
// T07. The bar the panel judges against — kept OUT of the title, which is printed
|
|
911
|
+
// on the public certificate. Normalised by a copy of src/lib/milestone-bar-input.ts
|
|
912
|
+
// (milestone-bar-input-parity.test.ts runs it against the owner).
|
|
913
|
+
let criteriaRaw = typeof args.criteria === "string" ? args.criteria : undefined;
|
|
914
|
+
if (typeof args["criteria-file"] === "string") criteriaRaw = readFileSync(args["criteria-file"], "utf8");
|
|
915
|
+
let checklistRaw;
|
|
916
|
+
if (typeof args["checklist-file"] === "string") {
|
|
917
|
+
try {
|
|
918
|
+
checklistRaw = JSON.parse(readFileSync(args["checklist-file"], "utf8"));
|
|
919
|
+
} catch (e) {
|
|
920
|
+
err(`--checklist-file: not a JSON array of { title, severity?, id? } (${e.message})`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const reviewMode = typeof args["review-mode"] === "string" ? args["review-mode"].toUpperCase() : undefined;
|
|
924
|
+
if (reviewMode !== undefined && !["AUTO", "MANUAL", "MANUAL_AUTO"].includes(reviewMode)) {
|
|
925
|
+
err("--review-mode must be AUTO, MANUAL or MANUAL_AUTO");
|
|
926
|
+
}
|
|
927
|
+
let criteria, checklist;
|
|
928
|
+
try {
|
|
929
|
+
criteria = normalizeCriteria(criteriaRaw);
|
|
930
|
+
checklist = normalizeMilestoneChecklist(checklistRaw);
|
|
931
|
+
} catch (e) {
|
|
932
|
+
err(e.message);
|
|
933
|
+
}
|
|
934
|
+
|
|
441
935
|
const data = await apiPost("/api/contracts", {
|
|
442
936
|
milestones: [{
|
|
443
937
|
title: args.title,
|
|
444
938
|
amountUSD,
|
|
445
939
|
cancelAfter: deadline.toISOString(),
|
|
940
|
+
...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
|
|
941
|
+
...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
|
|
446
942
|
}],
|
|
943
|
+
...(reviewMode !== undefined ? { agentReviewMode: reviewMode } : {}),
|
|
447
944
|
});
|
|
448
945
|
|
|
449
946
|
const contractUrl = `${BASE_URL}/contract/${data.contractId}`;
|
|
@@ -474,7 +971,14 @@ async function cmdEscrowFund(args) {
|
|
|
474
971
|
agentPrivateKey: args["private-key"],
|
|
475
972
|
};
|
|
476
973
|
if (args.milestone) body.milestoneId = args.milestone;
|
|
477
|
-
if (args.amount)
|
|
974
|
+
if (args.amount) {
|
|
975
|
+
// NOT forwarded. `agent/escrow-fund` funds `milestone.amountUSD` and ignores
|
|
976
|
+
// a body field, so `--amount` never overrode anything — it only told the
|
|
977
|
+
// user it had. Say so rather than sending a number the server drops.
|
|
978
|
+
process.stderr.write(
|
|
979
|
+
"\n⚠️ --amount is ignored for escrow funding: the escrowed amount is the milestone's own amountUSD.\n"
|
|
980
|
+
);
|
|
981
|
+
}
|
|
478
982
|
|
|
479
983
|
process.stderr.write("\nFunding milestone on-chain (approve + fundMilestone)...\n");
|
|
480
984
|
const data = await apiPost("/api/agent/escrow-fund", body);
|
|
@@ -494,7 +998,25 @@ async function cmdSubmit(args) {
|
|
|
494
998
|
args.proof
|
|
495
999
|
);
|
|
496
1000
|
print(data);
|
|
497
|
-
|
|
1001
|
+
// Upload starts verification by itself — `verify --proof` would only race it.
|
|
1002
|
+
process.stderr.write(`\nProof submitted; verification has started. Wait for the verdict: cascrow status --proof ${data.proofId} --wait\n\n`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function cmdStatus(args) {
|
|
1006
|
+
if (!API_KEY) err("CASCROW_API_KEY is not set");
|
|
1007
|
+
if (!args.proof) err("--proof is required");
|
|
1008
|
+
if (args.wait) {
|
|
1009
|
+
process.stderr.write("\nWaiting for the verification to settle (up to ~2 min)...\n\n");
|
|
1010
|
+
const s = await waitForProof(args.proof, 120_000);
|
|
1011
|
+
print(s);
|
|
1012
|
+
process.stderr.write("\n" + statusSummary(s) + "\n\n");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const data = await apiGet(`/api/agent/proof-status/${encodeURIComponent(args.proof)}`);
|
|
1016
|
+
print(data);
|
|
1017
|
+
process.stderr.write(
|
|
1018
|
+
"\n" + (data.pending && data.milestoneClosed !== true ? `⏳ Still verifying${data.claimPhase === "orphaned" ? " — the run was interrupted" : ""}.` : statusSummary({ ...data, proofId: args.proof })) + "\n\n"
|
|
1019
|
+
);
|
|
498
1020
|
}
|
|
499
1021
|
|
|
500
1022
|
async function cmdGet(args) {
|
|
@@ -540,6 +1062,62 @@ async function cmdCheckInvites() {
|
|
|
540
1062
|
}
|
|
541
1063
|
}
|
|
542
1064
|
|
|
1065
|
+
/**
|
|
1066
|
+
* The funder's pending-verdict queue, and the verdict itself.
|
|
1067
|
+
*
|
|
1068
|
+
* Both go through the routes every other surface uses — `GET
|
|
1069
|
+
* /api/agent/review-queue` and `POST /api/contracts/review` — which ask the same
|
|
1070
|
+
* owner module, so the queue this prints and the queue the door accepts a
|
|
1071
|
+
* verdict on cannot come apart (F-22).
|
|
1072
|
+
*
|
|
1073
|
+
* `review-queue` is the pull behind the `manual_review.required` webhook: a
|
|
1074
|
+
* webhook nobody received is a decision nobody makes, and cascrow auto-approves
|
|
1075
|
+
* an undecided milestone after 48 h (agent contracts) or 14 days (human) and
|
|
1076
|
+
* releases the escrow. `review` ships with it rather than later, because a list
|
|
1077
|
+
* its reader cannot act on names a remedy they are unable to perform (F-72).
|
|
1078
|
+
*/
|
|
1079
|
+
async function cmdReviewQueue() {
|
|
1080
|
+
if (!API_KEY) err("CASCROW_API_KEY is not set");
|
|
1081
|
+
const data = await apiGet("/api/agent/review-queue");
|
|
1082
|
+
const contracts = data.contracts ?? [];
|
|
1083
|
+
print(data);
|
|
1084
|
+
if (contracts.length === 0) {
|
|
1085
|
+
process.stderr.write("\nNo milestones are waiting for your verdict.\n\n");
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const lines = contracts.map((c) => {
|
|
1089
|
+
const pending = c.pendingMilestones ?? [];
|
|
1090
|
+
const ms =
|
|
1091
|
+
pending.length === 1
|
|
1092
|
+
? `milestone ${pending[0].milestoneId}`
|
|
1093
|
+
: pending.length > 1
|
|
1094
|
+
? `${pending.length} milestones pending — pass --milestone <id>`
|
|
1095
|
+
: "contract-level review — omit --milestone";
|
|
1096
|
+
return ` ${c.contractId} ${c.amountUSD} RLUSD (${ms})`;
|
|
1097
|
+
}).join("\n");
|
|
1098
|
+
const more = data.truncated ? `\n(${data.total} in total — this list is capped.)` : "";
|
|
1099
|
+
process.stderr.write(
|
|
1100
|
+
`\n${data.total} contract(s) waiting for your verdict:\n${lines}${more}\n\n` +
|
|
1101
|
+
"Decide with: cascrow review --contract <id> --decision APPROVE|REJECT [--milestone <id>]\n\n"
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function cmdReview(args) {
|
|
1106
|
+
if (!API_KEY) err("CASCROW_API_KEY is not set");
|
|
1107
|
+
const contractId = args.contract;
|
|
1108
|
+
const decision = (args.decision ?? "").toUpperCase();
|
|
1109
|
+
if (!contractId) err("--contract <id> is required (see: cascrow review-queue)");
|
|
1110
|
+
if (decision !== "APPROVE" && decision !== "REJECT") err("--decision must be APPROVE or REJECT");
|
|
1111
|
+
const body = args.milestone ? { contractId, decision, milestoneId: args.milestone } : { contractId, decision };
|
|
1112
|
+
const data = await apiPost("/api/contracts/review", body);
|
|
1113
|
+
print(data);
|
|
1114
|
+
const what =
|
|
1115
|
+
decision === "APPROVE"
|
|
1116
|
+
? "approved — the escrow is being released to the builder"
|
|
1117
|
+
: "rejected — the milestone is back with the builder, who can resubmit";
|
|
1118
|
+
process.stderr.write(`\nMilestone ${what}.${data.txHash ? `\nRelease tx: ${data.txHash}` : ""}\n\n`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
543
1121
|
async function cmdMe() {
|
|
544
1122
|
if (!API_KEY) err("CASCROW_API_KEY is not set");
|
|
545
1123
|
const data = await apiGet("/api/agent/me");
|
|
@@ -565,10 +1143,13 @@ try {
|
|
|
565
1143
|
case "fund": await cmdFund(args); break;
|
|
566
1144
|
case "escrow-fund": await cmdEscrowFund(args); break;
|
|
567
1145
|
case "submit": await cmdSubmit(args); break;
|
|
1146
|
+
case "status": await cmdStatus(args); break;
|
|
568
1147
|
case "get": await cmdGet(args); break;
|
|
569
1148
|
case "join": await cmdJoin(args); break;
|
|
570
1149
|
case "handoff": await cmdHandoff(args); break;
|
|
571
1150
|
case "check-invites": await cmdCheckInvites(); break;
|
|
1151
|
+
case "review-queue": await cmdReviewQueue(); break;
|
|
1152
|
+
case "review": await cmdReview(args); break;
|
|
572
1153
|
case "me": await cmdMe(); break;
|
|
573
1154
|
default:
|
|
574
1155
|
console.error(`Unknown command: ${command}\n`);
|