premanmcp 0.10.5 → 0.10.7

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/bin/tests.js ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * `preman tests generate|review|setup|enrich` — Collections workbench test generation.
3
+ *
4
+ * Thin HTTP wrappers over the same `/workbench/generate-tests` routes the
5
+ * dashboard uses. Distinct from `preman test <id>`, which generates + runs
6
+ * scenarios for one endpoint via `POST /mcp/call-tool`.
7
+ */
8
+
9
+ import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
10
+
11
+ export const TESTS_HELP = `
12
+ Collections tests:
13
+ preman tests generate [--workspace id] [--integration-id id] [--json-out]
14
+ Scan the connected GitHub repo and write heuristic suites on saved requests.
15
+ No LLM. Writes and ambiguous routes are flagged for review.
16
+ preman tests review [--workspace id] [--json-out]
17
+ List suites still flagged (review_status=required) with the reason.
18
+ preman tests review --approve <request_id> [--workspace id] [--json-out]
19
+ Approve one flagged suite so it can be scheduled. Does not raise
20
+ unattended_policy. DELETE stays skipped unless allow_destructive.
21
+ preman tests review --all-required [--workspace id] [--json-out]
22
+ Approve every currently flagged suite. Same gates as --approve.
23
+ preman tests setup [--list] [--workspace id] [--json-out]
24
+ Harvest path-param fixture IDs from list GETs / schema examples.
25
+ Stores them on the workspace and prints a .env snippet.
26
+ Does not invent UUIDs, create secrets, or approve suites.
27
+ --list shows missing fixtures without calling the API.
28
+ preman tests enrich [--list] [--workspace id] [--json-out]
29
+ LLM-enrich remaining review-cleared heuristic suites (cap 40).
30
+ --list shows the remaining queue without calling the model.
31
+ Skips destructive/billing unless standing policy already allows that risk.
32
+ Does not enable writes and does not publish a Guard baseline.
33
+ `;
34
+
35
+ class CliError extends Error {
36
+ constructor(message, exitCode = 1) {
37
+ super(message);
38
+ this.exitCode = exitCode;
39
+ }
40
+ }
41
+
42
+ function requireKey(args) {
43
+ const token = resolveApiKey(args);
44
+ if (!token) {
45
+ throw new CliError(
46
+ `No PreMan API key. Run \`${cliInvocation()} login\` or pass --api-key pm_live_...`,
47
+ 2,
48
+ );
49
+ }
50
+ return token;
51
+ }
52
+
53
+ function workspaceHeaders(args) {
54
+ return { "x-workspace-id": args.value("--workspace", "") };
55
+ }
56
+
57
+ function wantsJson(args) {
58
+ return args.has("--json-out") || args.has("--json");
59
+ }
60
+
61
+ function printJson(value) {
62
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
63
+ }
64
+
65
+ function errorDetail(result) {
66
+ const detail = result.detail;
67
+ if (typeof detail === "string") return detail;
68
+ if (Array.isArray(detail)) {
69
+ return detail.map((item) => item.msg || JSON.stringify(item)).join("; ");
70
+ }
71
+ if (detail && typeof detail === "object") {
72
+ return detail.message || detail.error || JSON.stringify(detail);
73
+ }
74
+ return result.message || result.raw || "backend error";
75
+ }
76
+
77
+ async function workbench(args, method, routePath, { json } = {}) {
78
+ const token = requireKey(args);
79
+ const result = await callBackendJson(args, method, routePath, {
80
+ token,
81
+ json,
82
+ headers: workspaceHeaders(args),
83
+ });
84
+ if (!result.ok) {
85
+ throw new CliError(
86
+ `${method} ${routePath} failed: ${result.status_code} ${errorDetail(result)}`,
87
+ );
88
+ }
89
+ const { status_code, ...payload } = result;
90
+ return payload;
91
+ }
92
+
93
+ function flagLine(item) {
94
+ const method = item.method || "";
95
+ const url = item.url || item.name || "";
96
+ const reason = item.reason_label || item.reason || "Needs review";
97
+ const id = item.request_id || "";
98
+ return ` ${id} ${method} ${url} ${reason}`;
99
+ }
100
+
101
+ export function printGenerateSummary(payload) {
102
+ const generated = payload.suites_generated || 0;
103
+ const repo = payload.repo_url || "the connected repository";
104
+ process.stdout.write(`Generated ${generated} suite(s) from ${repo}\n`);
105
+ process.stdout.write(
106
+ ` scanned ${payload.scanned || 0} matched ${payload.matched || 0} unmatched ${payload.unmatched || 0}\n`,
107
+ );
108
+ process.stdout.write(
109
+ ` auto-approved ${payload.auto_approved || 0} flagged ${payload.flagged_for_review || 0}` +
110
+ ` waiting for base URL ${payload.waiting_for_base_url || 0}\n`,
111
+ );
112
+ if (payload.bodies_filled_from_schema) {
113
+ process.stdout.write(` bodies filled from schema ${payload.bodies_filled_from_schema}\n`);
114
+ }
115
+ const flagged = payload.flagged || [];
116
+ if (flagged.length) {
117
+ process.stdout.write(`\n${flagged.length} flagged for review:\n`);
118
+ for (const item of flagged) process.stdout.write(`${flagLine(item)}\n`);
119
+ process.stdout.write(
120
+ `\nNext: \`${cliInvocation()} tests review --approve <request_id>\` enables a read-only schedule only.\n`,
121
+ );
122
+ } else if ((payload.flagged_for_review || 0) === 0 && generated) {
123
+ process.stdout.write(
124
+ `\nNo suites flagged. Optional: \`${cliInvocation()} tests enrich\` to add LLM cases to remaining heuristic suites.\n`,
125
+ );
126
+ }
127
+ const samples = payload.unmatched_samples || [];
128
+ if (samples.length) {
129
+ process.stdout.write(`\nUnmatched repo routes (not imported):\n`);
130
+ for (const sample of samples.slice(0, 10)) process.stdout.write(` ${sample}\n`);
131
+ }
132
+ }
133
+
134
+ export function printReviewList(payload) {
135
+ const items = payload.items || [];
136
+ const total = payload.total ?? items.length;
137
+ process.stdout.write(`${total} suite(s) flagged for review\n`);
138
+ for (const item of items) process.stdout.write(`${flagLine(item)}\n`);
139
+ if (total) {
140
+ process.stdout.write(
141
+ `\nApprove one: \`${cliInvocation()} tests review --approve <request_id>\`\n` +
142
+ `Approve enables a read-only schedule only. DELETE stays skipped unless allow_destructive.\n`,
143
+ );
144
+ }
145
+ }
146
+
147
+ export function printApproveResult(payload) {
148
+ const id = payload.request_id || "";
149
+ const status = payload.review_status || "approved";
150
+ const enabled = payload.enabled ? "enabled" : "disabled";
151
+ process.stdout.write(`Approved ${id} ${status} ${enabled}\n`);
152
+ process.stdout.write(
153
+ "Schedule is enabled. unattended_policy is unchanged; DELETE still skips unless allow_destructive.\n",
154
+ );
155
+ }
156
+
157
+ export function printApproveAll(results) {
158
+ process.stdout.write(`Approved ${results.length} flagged suite(s)\n`);
159
+ for (const payload of results) {
160
+ const enabled = payload.enabled ? "enabled" : "disabled";
161
+ process.stdout.write(` ${payload.request_id || ""} ${payload.review_status || "approved"} ${enabled}\n`);
162
+ }
163
+ if (results.length) {
164
+ process.stdout.write(
165
+ "Schedule is enabled. unattended_policy is unchanged; DELETE still skips unless allow_destructive.\n",
166
+ );
167
+ }
168
+ }
169
+
170
+ export function printEnrichList(payload) {
171
+ const items = payload.items || [];
172
+ const total = payload.total ?? items.length;
173
+ process.stdout.write(`${total} suite(s) remaining for agent enrichment\n`);
174
+ for (const item of items) process.stdout.write(`${flagLine(item)}\n`);
175
+ if (total) {
176
+ process.stdout.write(
177
+ `\nRun \`${cliInvocation()} tests enrich\` to append LLM cases (cap 40). Does not enable writes.\n`,
178
+ );
179
+ }
180
+ }
181
+
182
+ export function printSetupList(payload) {
183
+ const items = (payload.items || []).filter((item) => !item.value_set);
184
+ const total = payload.total ?? items.length;
185
+ process.stdout.write(`${total} path-param fixture(s) still missing\n`);
186
+ for (const item of payload.items || []) {
187
+ const state = item.value_set ? "set" : "missing";
188
+ process.stdout.write(
189
+ ` ${item.name || item.param || ""} ${item.method || ""} ${item.url || ""} ${state}\n`,
190
+ );
191
+ }
192
+ if (payload.dotenv) {
193
+ process.stdout.write(`\nCurrent .env:\n${payload.dotenv}`);
194
+ }
195
+ if (total) {
196
+ process.stdout.write(
197
+ `\nRun \`${cliInvocation()} tests setup\` to harvest ids from list GETs.\n`,
198
+ );
199
+ }
200
+ }
201
+
202
+ export function printSetupSummary(payload) {
203
+ process.stdout.write(
204
+ `Stored ${payload.stored || 0} fixture(s). Missing ${payload.missing || 0}.\n`,
205
+ );
206
+ process.stdout.write(
207
+ ` harvested ${payload.harvested || 0}` +
208
+ ` list GETs ${payload.list_gets_called || 0}` +
209
+ ` from lists ${payload.from_list_get || 0}` +
210
+ ` from schema ${payload.from_schema || 0}\n`,
211
+ );
212
+ if (payload.dotenv) {
213
+ process.stdout.write(`\n.env\n${payload.dotenv}`);
214
+ }
215
+ const missing = payload.missing_items || [];
216
+ if (missing.length) {
217
+ process.stdout.write(`\nStill missing:\n`);
218
+ for (const item of missing) {
219
+ process.stdout.write(` ${item.name || item.param || ""} ${item.method || ""} ${item.url || ""}\n`);
220
+ }
221
+ }
222
+ }
223
+
224
+ export function printEnrichSummary(payload) {
225
+ process.stdout.write(
226
+ `Enriched ${payload.enriched || 0} suite(s). Remaining ${payload.remaining || 0}.\n`,
227
+ );
228
+ process.stdout.write(
229
+ ` skipped sensitive ${payload.skipped_sensitive || 0}` +
230
+ ` pending review ${payload.skipped_pending_review || 0}` +
231
+ ` already enriched ${payload.skipped_already_enriched || 0}` +
232
+ ` no suite ${payload.skipped_no_suite || 0}` +
233
+ ` failed ${payload.failed || 0}\n`,
234
+ );
235
+ const remaining = payload.remaining_items || [];
236
+ if (remaining.length) {
237
+ process.stdout.write(`\nStill remaining:\n`);
238
+ for (const item of remaining) process.stdout.write(`${flagLine(item)}\n`);
239
+ }
240
+ }
241
+
242
+ function requestIdFlag(args, flag) {
243
+ const value = args.value(flag, "");
244
+ if (!value || value.startsWith("-")) {
245
+ throw new CliError(`pass a request id after ${flag}${TESTS_HELP}`, 2);
246
+ }
247
+ return value;
248
+ }
249
+
250
+ async function generateCommand(args) {
251
+ const integrationId = args.value("--integration-id", "");
252
+ const json = {};
253
+ if (integrationId && !integrationId.startsWith("-")) json.integration_id = integrationId;
254
+ const payload = await workbench(args, "POST", "/workbench/generate-tests", { json });
255
+ if (wantsJson(args)) return printJson(payload);
256
+ printGenerateSummary(payload);
257
+ return payload;
258
+ }
259
+
260
+ async function reviewCommand(args) {
261
+ if (args.has("--approve") && args.has("--all-required")) {
262
+ throw new CliError("use either --approve <request_id> or --all-required, not both", 2);
263
+ }
264
+
265
+ if (args.has("--approve")) {
266
+ const requestId = encodeURIComponent(requestIdFlag(args, "--approve"));
267
+ const payload = await workbench(
268
+ args,
269
+ "POST",
270
+ `/workbench/generate-tests/review/${requestId}/approve`,
271
+ );
272
+ if (wantsJson(args)) return printJson(payload);
273
+ printApproveResult(payload);
274
+ return payload;
275
+ }
276
+
277
+ const listed = await workbench(args, "GET", "/workbench/generate-tests/review");
278
+ if (args.has("--all-required")) {
279
+ const items = listed.items || [];
280
+ const results = [];
281
+ for (const item of items) {
282
+ const requestId = encodeURIComponent(item.request_id || "");
283
+ if (!requestId) continue;
284
+ results.push(
285
+ await workbench(args, "POST", `/workbench/generate-tests/review/${requestId}/approve`),
286
+ );
287
+ }
288
+ if (wantsJson(args)) return printJson({ approved: results, listed });
289
+ if (!results.length) {
290
+ process.stdout.write("0 suite(s) flagged for review\n");
291
+ return { approved: results, listed };
292
+ }
293
+ printApproveAll(results);
294
+ return { approved: results, listed };
295
+ }
296
+
297
+ if (wantsJson(args)) return printJson(listed);
298
+ printReviewList(listed);
299
+ return listed;
300
+ }
301
+
302
+ async function setupCommand(args) {
303
+ if (args.has("--list")) {
304
+ const listed = await workbench(args, "GET", "/workbench/generate-tests/fixtures");
305
+ if (wantsJson(args)) return printJson(listed);
306
+ printSetupList(listed);
307
+ return listed;
308
+ }
309
+ const payload = await workbench(args, "POST", "/workbench/generate-tests/fixtures");
310
+ if (wantsJson(args)) return printJson(payload);
311
+ printSetupSummary(payload);
312
+ return payload;
313
+ }
314
+
315
+ async function enrichCommand(args) {
316
+ if (args.has("--list")) {
317
+ const listed = await workbench(args, "GET", "/workbench/generate-tests/enrich");
318
+ if (wantsJson(args)) return printJson(listed);
319
+ printEnrichList(listed);
320
+ return listed;
321
+ }
322
+ const payload = await workbench(args, "POST", "/workbench/generate-tests/enrich");
323
+ if (wantsJson(args)) return printJson(payload);
324
+ printEnrichSummary(payload);
325
+ return payload;
326
+ }
327
+
328
+ export async function testsCommand(commandArgs = []) {
329
+ const sub = commandArgs[0] && !commandArgs[0].startsWith("-") ? commandArgs[0] : "";
330
+ const rest = sub ? commandArgs.slice(1) : commandArgs;
331
+ const args = makeArgs(rest);
332
+
333
+ if (!sub || sub === "help") {
334
+ throw new CliError(`pass generate, review, setup, or enrich${TESTS_HELP}`, 2);
335
+ }
336
+ if (sub === "generate") return generateCommand(args);
337
+ if (sub === "review") return reviewCommand(args);
338
+ if (sub === "setup") return setupCommand(args);
339
+ if (sub === "enrich") return enrichCommand(args);
340
+ throw new CliError(`unknown tests subcommand: ${sub}${TESTS_HELP}`, 2);
341
+ }
package/bin/verify.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * unless the repository has explicitly opted into blocking.
19
19
  */
20
20
 
21
+ import { randomUUID } from "node:crypto";
21
22
  import { readFileSync } from "node:fs";
22
23
  import os from "node:os";
23
24
  import path from "node:path";
@@ -25,7 +26,7 @@ import path from "node:path";
25
26
  import { changedFilesForPush, readChangedFiles, readPushRefsFromStdin, repoRoot } from "./changed.js";
26
27
  import { resolveLocalTarget } from "./detect.js";
27
28
  import { createReporter } from "./progress.js";
28
- import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
29
+ import { backendUrl, callBackendJson, cliInvocation, makeArgs, nowMs, resolveApiKey } from "./shared.js";
29
30
 
30
31
  export const VERIFY_HELP = `
31
32
  Verify options:
@@ -42,6 +43,10 @@ Verify options:
42
43
  const DEFAULT_TIMEOUT_SECONDS = 120;
43
44
  const REQUEST_TIMEOUT_MS = 10_000;
44
45
  const CONCURRENCY = 6;
46
+ const INVENTORY_PAGE_SIZE = 200;
47
+ const INVENTORY_MAX_ENDPOINTS = 2000;
48
+ // POST /projects/{id}/api-runs rejects more than this (schemas.pulse.MAX_UPLOAD_RUNS).
49
+ const UPLOAD_BATCH_SIZE = 200;
45
50
  const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
46
51
  // Never fired automatically even against loopback: a destructive verb that
47
52
  // happens to point at a real datastore is not made safe by the port being local.
@@ -55,10 +60,6 @@ const REPO_CONFIG = ".preman.json";
55
60
  // push — the exact failure the advisory guarantee exists to prevent.
56
61
  export const BLOCK_EXIT_CODE = 97;
57
62
 
58
- function nowMs() {
59
- return Number(process.hrtime.bigint() / 1000000n);
60
- }
61
-
62
63
  function originLabel() {
63
64
  const raw = `${os.hostname() || "local"}`.toLowerCase();
64
65
  const cleaned = raw.replace(/[^a-z0-9._-]/g, "-").replace(/^[^a-z0-9]+/, "");
@@ -103,7 +104,7 @@ async function runCase(baseUrl, endpoint, { batchId }) {
103
104
  client_run_id: `${batchId}:${method}:${endpoint.path_template || endpoint.path}`,
104
105
  method,
105
106
  url: target,
106
- status: "errored",
107
+ status: "error",
107
108
  http_status: null,
108
109
  latency_ms: nowMs() - t0,
109
110
  error_message: aborted ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : String(error?.message || error),
@@ -201,7 +202,7 @@ async function runGeneratedCase(testCase, { batchId, endpoint }) {
201
202
  endpoint: `${endpoint.method} ${endpoint.path}`,
202
203
  method,
203
204
  url: testCase.url,
204
- status: "errored",
205
+ status: "error",
205
206
  http_status: null,
206
207
  latency_ms: nowMs() - t0,
207
208
  error_message: aborted ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : String(error?.message || error),
@@ -239,19 +240,66 @@ async function safeCall(args, method, routePath, options) {
239
240
  }
240
241
 
241
242
  async function fetchInventory(args, token) {
242
- const result = await safeCall(args, "POST", "/mcp/call-tool", {
243
- token,
244
- json: { tool: "get_endpoints", arguments: { format: "json" } },
245
- });
246
- if (!result.ok) return { endpoints: [], error: result.detail || `status ${result.status_code}` };
247
-
248
- const payload = result.result ?? result.data ?? result;
249
- const endpoints =
250
- payload?.endpoints ||
251
- payload?.result?.endpoints ||
252
- (Array.isArray(payload) ? payload : []) ||
253
- [];
254
- return { endpoints: Array.isArray(endpoints) ? endpoints : [] };
243
+ const endpoints = [];
244
+ for (let offset = 0; offset < INVENTORY_MAX_ENDPOINTS; offset += INVENTORY_PAGE_SIZE) {
245
+ const result = await safeCall(args, "POST", "/mcp/call-tool", {
246
+ token,
247
+ json: {
248
+ tool: "get_endpoints",
249
+ arguments: { format: "json", limit: INVENTORY_PAGE_SIZE, offset },
250
+ },
251
+ });
252
+ if (!result.ok) {
253
+ return {
254
+ endpoints,
255
+ error: result.detail || `status ${result.status_code}`,
256
+ };
257
+ }
258
+
259
+ const payload = result.result ?? result.data ?? result;
260
+ const page =
261
+ payload?.endpoints ||
262
+ payload?.result?.endpoints ||
263
+ (Array.isArray(payload) ? payload : []) ||
264
+ [];
265
+ const rows = Array.isArray(page) ? page : [];
266
+ endpoints.push(...rows);
267
+ const pagination = payload?.pagination || payload?.result?.pagination;
268
+ if (!pagination?.has_more || rows.length === 0) break;
269
+ if (offset + INVENTORY_PAGE_SIZE >= INVENTORY_MAX_ENDPOINTS) {
270
+ return {
271
+ endpoints,
272
+ error: `endpoint inventory exceeds the ${INVENTORY_MAX_ENDPOINTS}-endpoint verification limit`,
273
+ incomplete: true,
274
+ };
275
+ }
276
+ }
277
+ return { endpoints };
278
+ }
279
+
280
+ async function uploadRuns(args, { token, projectId, batchId, runs }) {
281
+ if (!projectId || !runs.length) return { inserted: 0, error: null };
282
+ let inserted = 0;
283
+ for (let offset = 0; offset < runs.length; offset += UPLOAD_BATCH_SIZE) {
284
+ const chunk = runs.slice(offset, offset + UPLOAD_BATCH_SIZE);
285
+ const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
286
+ token,
287
+ json: {
288
+ origin: "local",
289
+ origin_label: originLabel(),
290
+ source: "ci",
291
+ batch_id: batchId,
292
+ batch_label: "pre-push verify",
293
+ runs: chunk,
294
+ },
295
+ });
296
+ if (!upload.ok) {
297
+ const detail = upload.detail || (upload.status_code ? `HTTP ${upload.status_code}` : "request failed");
298
+ return { inserted, error: `verification results were not uploaded: ${detail}` };
299
+ }
300
+ inserted += Number(upload.inserted || 0);
301
+ }
302
+ return { inserted, error: null };
255
303
  }
256
304
 
257
305
  function selectMethods(args) {
@@ -390,7 +438,7 @@ async function runPlan(
390
438
  stream: asJson ? process.stderr : process.stdout,
391
439
  live: asJson ? false : undefined,
392
440
  });
393
- const batchId = `prepush-${Date.now().toString(36)}`;
441
+ const batchId = randomUUID();
394
442
  const changes = plan.changes || [];
395
443
 
396
444
  if (!asJson) {
@@ -449,21 +497,8 @@ async function runPlan(
449
497
  );
450
498
  const removed = changes.filter((c) => c.change_type === "removed");
451
499
 
452
- let uploaded = 0;
453
- if (projectId && completed.length) {
454
- const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
455
- token,
456
- json: {
457
- origin: "local",
458
- origin_label: originLabel(),
459
- source: "ci",
460
- batch_id: batchId,
461
- batch_label: "pre-push verify",
462
- runs: completed,
463
- },
464
- });
465
- if (upload.ok) uploaded = Number(upload.inserted || 0);
466
- }
500
+ const upload = await uploadRuns(args, { token, projectId, batchId, runs: completed });
501
+ const uploaded = upload.inserted;
467
502
 
468
503
  const gate = blockOnBreaking(args, root);
469
504
  const shouldBlock = gate.blocking && (breaking.length > 0 || removed.length > 0);
@@ -479,6 +514,7 @@ async function runPlan(
479
514
  }
480
515
  if (withheld) reporter.line(`[preman] ${withheld} mutating case${withheld === 1 ? "" : "s"} withheld`);
481
516
  if (uploaded) reporter.line(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}`);
517
+ if (upload.error) reporter.line(`[preman] ${upload.error}`);
482
518
  if (shouldBlock) {
483
519
  reporter.line(`[preman] blocking this push (${gate.from}). Set PREMAN_SKIP_HOOK=1 to override.`);
484
520
  } else if (breaking.length || removed.length) {
@@ -518,6 +554,7 @@ async function runPlan(
518
554
  error: r.error_message || null,
519
555
  })),
520
556
  uploaded,
557
+ upload_error: upload.error,
521
558
  // Advisory unless the repository opted in. `advisory` only flattens an
522
559
  // unexpected throw; a deliberate block has to survive it.
523
560
  exitCode: shouldBlock ? BLOCK_EXIT_CODE : 0,
@@ -589,7 +626,8 @@ async function runVerify(args) {
589
626
  }
590
627
 
591
628
  const extraPorts = args.value("--port", "") ? [args.value("--port", "")] : [];
592
- const resolved = await resolveLocalTarget([...planEndpoints, ...endpoints], {
629
+ const confirmationEndpoints = planEndpoints.length ? planEndpoints : endpoints;
630
+ const resolved = await resolveLocalTarget(confirmationEndpoints, {
593
631
  extraPorts,
594
632
  timeoutMs: 2000,
595
633
  });
@@ -605,6 +643,7 @@ async function runVerify(args) {
605
643
 
606
644
  const target = resolved.target;
607
645
  const policy = selectMethods(args);
646
+ const targetInventory = resolved.inventory || endpoints;
608
647
 
609
648
  if (planned.plan?.selected?.length) {
610
649
  return finish(
@@ -623,7 +662,7 @@ async function runVerify(args) {
623
662
  );
624
663
  }
625
664
 
626
- const selected = endpoints.filter((ep) => {
665
+ const selected = targetInventory.filter((ep) => {
627
666
  const method = String(ep.method || "GET").toUpperCase();
628
667
  if (READ_METHODS.has(method)) return true;
629
668
  if (DESTRUCTIVE_METHODS.has(method)) return false;
@@ -638,7 +677,7 @@ async function runVerify(args) {
638
677
  );
639
678
  }
640
679
 
641
- const batchId = `prepush-${Date.now().toString(36)}`;
680
+ const batchId = randomUUID();
642
681
  const runs = await runPool(
643
682
  selected,
644
683
  (endpoint) =>
@@ -650,21 +689,8 @@ async function runVerify(args) {
650
689
  const completed = runs.filter(Boolean);
651
690
  const failed = completed.filter((r) => r.status !== "passed");
652
691
 
653
- let uploaded = 0;
654
- if (projectId && completed.length) {
655
- const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
656
- token,
657
- json: {
658
- origin: "local",
659
- origin_label: originLabel(),
660
- source: "ci",
661
- batch_id: batchId,
662
- batch_label: "pre-push verify",
663
- runs: completed,
664
- },
665
- });
666
- if (upload.ok) uploaded = Number(upload.inserted || 0);
667
- }
692
+ const upload = await uploadRuns(args, { token, projectId, batchId, runs: completed });
693
+ const uploaded = upload.inserted;
668
694
 
669
695
  const outcome = {
670
696
  state: failed.length ? "failed" : "passed",
@@ -678,6 +704,7 @@ async function runVerify(args) {
678
704
  error: r.error_message || null,
679
705
  })),
680
706
  uploaded,
707
+ upload_error: upload.error,
681
708
  // Advisory by construction: a real failure still reports zero so the push
682
709
  // proceeds. The report is the product, not the gate.
683
710
  exitCode: 0,
@@ -695,6 +722,7 @@ async function runVerify(args) {
695
722
  process.stdout.write(`[preman] all ${completed.length} endpoints healthy\n`);
696
723
  }
697
724
  if (uploaded) process.stdout.write(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}\n`);
725
+ if (upload.error) process.stdout.write(`[preman] ${upload.error}\n`);
698
726
  }
699
727
 
700
728
  return finish({ ...outcome, message: null });