premanmcp 1.0.5 → 1.1.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/bin/cli.js +36 -1
- package/bin/eval.js +1200 -0
- package/bin/eval_harness.py +231 -0
- package/bin/eval_target.js +530 -0
- package/bin/link.js +81 -6
- package/bin/runner.js +193 -7
- package/bin/shared.js +37 -2
- package/package.json +2 -2
package/bin/runner.js
CHANGED
|
@@ -38,7 +38,10 @@ import os from "node:os";
|
|
|
38
38
|
import path from "node:path";
|
|
39
39
|
import { fileURLToPath } from "node:url";
|
|
40
40
|
|
|
41
|
-
import {
|
|
41
|
+
import { executeEvalRun } from "./eval.js";
|
|
42
|
+
import { discoverTarget, startAdapter, stepFeed } from "./eval_target.js";
|
|
43
|
+
import { resolveEvalSurface } from "./link.js";
|
|
44
|
+
import { CREDENTIALS_DIR, backendUrl, callBackendJson, cliInvocation, makeArgs, nowMs, onPath, openUrl, packageVersion, resolveApiKey } from "./shared.js";
|
|
42
45
|
|
|
43
46
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
44
47
|
|
|
@@ -70,6 +73,11 @@ const ERROR_CAP = 4_000;
|
|
|
70
73
|
export const RUNNER_CAPABILITIES = {
|
|
71
74
|
tool_call_telemetry_v1: true,
|
|
72
75
|
tool_fact_manifest_v1: true,
|
|
76
|
+
// `find_eval_runner` requires this before it will lease an eval here, and the
|
|
77
|
+
// requirement is not cosmetic: the stream parser below ignores an event name
|
|
78
|
+
// it does not know, so a build without eval support would take the lease,
|
|
79
|
+
// execute nothing, and look like a device that went offline.
|
|
80
|
+
agent_eval_v1: true,
|
|
73
81
|
transport: "cli",
|
|
74
82
|
client: "premanmcp",
|
|
75
83
|
};
|
|
@@ -398,6 +406,74 @@ async function callback(args, state, route, body, log) {
|
|
|
398
406
|
}
|
|
399
407
|
}
|
|
400
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Give a leased job straight back, because this session will not run it.
|
|
411
|
+
*
|
|
412
|
+
* Both protocols reach a terminal state through their own `complete`, and both
|
|
413
|
+
* accept "this device declines" as a legitimate outcome — which is why this is a
|
|
414
|
+
* completion with `ok: false` rather than an attempt to un-lease.
|
|
415
|
+
*/
|
|
416
|
+
async function declineJob(args, state, kind, job, log) {
|
|
417
|
+
const reason =
|
|
418
|
+
kind === "eval"
|
|
419
|
+
? "this session is running a coding-agent job, not evals"
|
|
420
|
+
: "this session is waiting on an eval and will not run coding-agent jobs";
|
|
421
|
+
log(`declining ${kind} ${job.id}: ${reason}`);
|
|
422
|
+
if (kind === "eval") {
|
|
423
|
+
return callback(
|
|
424
|
+
args,
|
|
425
|
+
state,
|
|
426
|
+
`/workbench/coding-agent/local-runner/evals/${job.id}/complete`,
|
|
427
|
+
{ lease_token: String(job.lease_token || ""), ok: false, error: reason },
|
|
428
|
+
log
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return callback(
|
|
432
|
+
args,
|
|
433
|
+
state,
|
|
434
|
+
`/workbench/coding-agent/local-runner/jobs/${job.id}/complete`,
|
|
435
|
+
{
|
|
436
|
+
lease_token: String(job.lease_token || ""),
|
|
437
|
+
ok: false,
|
|
438
|
+
error: reason,
|
|
439
|
+
attempt_count: Math.max(1, Number(job.attempt_count) || 1),
|
|
440
|
+
tool_events: [],
|
|
441
|
+
tool_call_count: 0,
|
|
442
|
+
metadata: { transport: "cli", declined: true },
|
|
443
|
+
},
|
|
444
|
+
log
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Hand a leased eval to `eval.js`, with this runner's way of making a callback.
|
|
450
|
+
*
|
|
451
|
+
* The callback shape is injected rather than imported there so the eval executor
|
|
452
|
+
* has no opinion about tokens or base URLs — this file stays the single place
|
|
453
|
+
* that knows a runner token goes on a runner request.
|
|
454
|
+
*/
|
|
455
|
+
export async function runLeasedEval(args, state, job, { log = () => {}, headless = true } = {}) {
|
|
456
|
+
// One callback seam for both kinds of traffic this run produces: JSON for the
|
|
457
|
+
// progress and completion reports, multipart for the artifact uploads. The
|
|
458
|
+
// executor should not have to know which transport a given route wants.
|
|
459
|
+
const call = (route, body) =>
|
|
460
|
+
callBackendJson(args, "POST", route, {
|
|
461
|
+
token: state.runner_token,
|
|
462
|
+
...(body instanceof FormData ? { form: body } : { json: body }),
|
|
463
|
+
});
|
|
464
|
+
const surface = resolveEvalSurface(args, { runId: job.id });
|
|
465
|
+
const result = await executeEvalRun(job, {
|
|
466
|
+
call,
|
|
467
|
+
log,
|
|
468
|
+
headless,
|
|
469
|
+
projectPath: state.project_path || process.cwd(),
|
|
470
|
+
surface: { ...surface, open: () => openUrl(surface.href) },
|
|
471
|
+
});
|
|
472
|
+
if (result.reason === "lease_lost") log(`eval ${job.id} was taken over or cancelled`);
|
|
473
|
+
else log(`eval ${job.id} finished: ${result.ok ? "ok" : "failed"}`);
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
401
477
|
export async function executeJob(args, state, job, { log = () => {}, fullAccess = false } = {}) {
|
|
402
478
|
const lease = String(job.lease_token || "");
|
|
403
479
|
const jobRoute = `/workbench/coding-agent/local-runner/jobs/${job.id}`;
|
|
@@ -600,6 +676,21 @@ function defaultLog(message) {
|
|
|
600
676
|
* leave a machine that reads as paired and never runs anything again. A missed
|
|
601
677
|
* heartbeat only costs one TTL window: the next one puts this runner back online.
|
|
602
678
|
*/
|
|
679
|
+
/**
|
|
680
|
+
* What this machine currently offers, refreshed on every heartbeat.
|
|
681
|
+
*
|
|
682
|
+
* Set by the daemon once it has an adapter listening, and deliberately *not*
|
|
683
|
+
* persisted in the runner state file. An adapter is a socket this process
|
|
684
|
+
* opened; a URL for one that outlived the process it belonged to is a URL that
|
|
685
|
+
* points at nothing, and writing it to disk is how it would come back after a
|
|
686
|
+
* restart looking authoritative.
|
|
687
|
+
*/
|
|
688
|
+
let advertised = null;
|
|
689
|
+
|
|
690
|
+
export function advertise(capabilities) {
|
|
691
|
+
advertised = capabilities;
|
|
692
|
+
}
|
|
693
|
+
|
|
603
694
|
export async function sendHeartbeat(args, state, { busy = false, log = () => {} } = {}) {
|
|
604
695
|
try {
|
|
605
696
|
// Reported honestly because the matcher prefers idle runners: a busy device
|
|
@@ -608,7 +699,17 @@ export async function sendHeartbeat(args, state, { busy = false, log = () => {}
|
|
|
608
699
|
args,
|
|
609
700
|
"POST",
|
|
610
701
|
"/workbench/coding-agent/local-runner/heartbeat",
|
|
611
|
-
{
|
|
702
|
+
{
|
|
703
|
+
token: state.runner_token,
|
|
704
|
+
json: {
|
|
705
|
+
state: busy ? "busy" : "idle",
|
|
706
|
+
// On every beat rather than once at registration, because both facts
|
|
707
|
+
// it carries expire: an agent restarted on a new port makes the old
|
|
708
|
+
// URL wrong, not merely stale, and a dashboard offering a Run button
|
|
709
|
+
// for it would fail on the press.
|
|
710
|
+
...(advertised ? { capabilities: advertised } : {}),
|
|
711
|
+
},
|
|
712
|
+
}
|
|
612
713
|
);
|
|
613
714
|
return { revoked: result.status_code === 401 };
|
|
614
715
|
} catch (error) {
|
|
@@ -617,6 +718,52 @@ export async function sendHeartbeat(args, state, { busy = false, log = () => {}
|
|
|
617
718
|
}
|
|
618
719
|
}
|
|
619
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Find this machine's agent and hold an adapter open for as long as the daemon runs.
|
|
723
|
+
*
|
|
724
|
+
* Why the daemon does this at startup rather than when a run arrives: the
|
|
725
|
+
* dashboard's Run button has to know, *before* anybody presses it, that there is
|
|
726
|
+
* something here to measure and where it is. That is what turns "copy this
|
|
727
|
+
* command into a terminal" into a button, and it is only knowable if the address
|
|
728
|
+
* exists ahead of the request rather than being created in response to one.
|
|
729
|
+
*
|
|
730
|
+
* Failure here is not fatal and must not be. A machine paired for fix tasks is
|
|
731
|
+
* perfectly useful with no agent to evaluate on it, and refusing to start the
|
|
732
|
+
* daemon because discovery came up empty would break the feature this one is
|
|
733
|
+
* bolted onto. The daemon says so once and carries on without eval capability.
|
|
734
|
+
*/
|
|
735
|
+
async function serveEvalTarget(args, { log }) {
|
|
736
|
+
let target;
|
|
737
|
+
try {
|
|
738
|
+
target = await discoverTarget(args, { log: () => {} });
|
|
739
|
+
} catch (error) {
|
|
740
|
+
log(`no agent to evaluate on this machine (${error.message}); eval runs will not be offered`);
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
const adapter = await startAdapter(target, args, {
|
|
746
|
+
log: () => {},
|
|
747
|
+
onStep: (step) => stepFeed.push(step),
|
|
748
|
+
});
|
|
749
|
+
if (adapter.translated) {
|
|
750
|
+
// Same reason as the interactive path: the address is a socket this
|
|
751
|
+
// process opened moments ago, and assert-ai's refusal to fetch private
|
|
752
|
+
// addresses is right in general and wrong for exactly this.
|
|
753
|
+
process.env.ASSERT_ALLOW_PRIVATE_ENDPOINTS = "1";
|
|
754
|
+
}
|
|
755
|
+
log(`offering ${target.label} for evals`);
|
|
756
|
+
advertise({
|
|
757
|
+
agent_eval_v1: true,
|
|
758
|
+
eval_target: { url: adapter.url, label: target.label, how: target.how },
|
|
759
|
+
});
|
|
760
|
+
return adapter;
|
|
761
|
+
} catch (error) {
|
|
762
|
+
log(`could not make this machine's agent answerable (${error.message}); eval runs will not be offered`);
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
620
767
|
/**
|
|
621
768
|
* Hold the stream and run what it leases, until stopped.
|
|
622
769
|
*
|
|
@@ -627,7 +774,17 @@ export async function sendHeartbeat(args, state, { busy = false, log = () => {}
|
|
|
627
774
|
export async function runnerLoop(
|
|
628
775
|
args,
|
|
629
776
|
state,
|
|
630
|
-
{
|
|
777
|
+
{
|
|
778
|
+
log = defaultLog,
|
|
779
|
+
once = false,
|
|
780
|
+
stopWhen = null,
|
|
781
|
+
fullAccess = false,
|
|
782
|
+
only = "",
|
|
783
|
+
// Headless unless a caller says otherwise. `preman runner` is a background
|
|
784
|
+
// daemon; only the interactive `preman eval run` has somebody in front of it
|
|
785
|
+
// to hand a run over to.
|
|
786
|
+
headless = true,
|
|
787
|
+
} = {}
|
|
631
788
|
) {
|
|
632
789
|
let stopped = false;
|
|
633
790
|
let busy = false;
|
|
@@ -677,7 +834,11 @@ export async function runnerLoop(
|
|
|
677
834
|
let terminal = "";
|
|
678
835
|
const push = createSseParser((event, data) => {
|
|
679
836
|
if (event === "connected") log(`connected as runner ${data.runner_id || state.runner_id}`);
|
|
680
|
-
else if (event === "job") queue.push(data);
|
|
837
|
+
else if (event === "job") queue.push({ kind: "fix", job: data });
|
|
838
|
+
// A distinct name rather than a discriminator inside `job`, so a runner
|
|
839
|
+
// older than eval support ignores the frame instead of reading an eval
|
|
840
|
+
// as a coding task and looking for a fix task that is not there.
|
|
841
|
+
else if (event === "eval_job") queue.push({ kind: "eval", job: data });
|
|
681
842
|
else if (event === "revoked" || event === "replaced") terminal = event;
|
|
682
843
|
else if (event === "reconnect") terminal = "reconnect";
|
|
683
844
|
});
|
|
@@ -687,11 +848,24 @@ export async function runnerLoop(
|
|
|
687
848
|
for await (const chunk of response.body) {
|
|
688
849
|
push(decoder.decode(chunk, { stream: true }));
|
|
689
850
|
while (queue.length) {
|
|
690
|
-
const job = queue.shift();
|
|
691
|
-
|
|
851
|
+
const { kind, job } = queue.shift();
|
|
852
|
+
// `preman eval run` is somebody waiting on one eval, not somebody
|
|
853
|
+
// volunteering their machine as a coding-agent runner. Executing a
|
|
854
|
+
// queued fix task because it happened to arrive down the same
|
|
855
|
+
// stream would edit files they did not ask to have edited.
|
|
856
|
+
//
|
|
857
|
+
// Declined rather than dropped: a leased job nobody answers sits
|
|
858
|
+
// until its lease expires, which delays the fix and tells whoever
|
|
859
|
+
// queued it nothing. Declining hands it straight back.
|
|
860
|
+
if (only && only !== kind) {
|
|
861
|
+
await declineJob(args, state, kind, job, log);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
log(`leased ${kind === "eval" ? "eval" : "job"} ${job.id}`);
|
|
692
865
|
busy = true;
|
|
693
866
|
try {
|
|
694
|
-
await
|
|
867
|
+
if (kind === "eval") await runLeasedEval(args, state, job, { log, headless });
|
|
868
|
+
else await executeJob(args, state, job, { log, fullAccess });
|
|
695
869
|
} finally {
|
|
696
870
|
busy = false;
|
|
697
871
|
}
|
|
@@ -876,6 +1050,14 @@ async function startForeground(args, commandArgs) {
|
|
|
876
1050
|
process.on("uncaughtException", die("uncaught exception"));
|
|
877
1051
|
process.on("unhandledRejection", die("unhandled rejection"));
|
|
878
1052
|
|
|
1053
|
+
const adapter = await serveEvalTarget(args, { log });
|
|
1054
|
+
|
|
1055
|
+
// Pushed immediately rather than waiting for the loop's first tick. The
|
|
1056
|
+
// capability only reaches the server on a heartbeat, so without this the
|
|
1057
|
+
// dashboard spends the first interval telling somebody their machine has
|
|
1058
|
+
// found no agent — while the terminal beside it says it has.
|
|
1059
|
+
if (adapter) await sendHeartbeat(args, state, { log });
|
|
1060
|
+
|
|
879
1061
|
try {
|
|
880
1062
|
const result = await runnerLoop(args, state, {
|
|
881
1063
|
log,
|
|
@@ -884,6 +1066,10 @@ async function startForeground(args, commandArgs) {
|
|
|
884
1066
|
});
|
|
885
1067
|
log(`stopped after ${result.jobsRun} job(s): ${result.reason}`);
|
|
886
1068
|
} finally {
|
|
1069
|
+
// Closed on every path. The adapter is an unauthenticated door to this
|
|
1070
|
+
// machine's agent, held open only for as long as something is listening for
|
|
1071
|
+
// work behind it.
|
|
1072
|
+
if (adapter) await adapter.close().catch(() => {});
|
|
887
1073
|
if (!shuttingDown) {
|
|
888
1074
|
await goOffline(args, state, log);
|
|
889
1075
|
rmSync(RUNNER_PID_FILE, { force: true });
|
package/bin/shared.js
CHANGED
|
@@ -263,6 +263,39 @@ export function frontendUrl(args) {
|
|
|
263
263
|
.replace(/\/+$/, "");
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The dashboard that can actually show a thing living on *this* backend, or
|
|
268
|
+
* nothing when there is no way to know.
|
|
269
|
+
*
|
|
270
|
+
* `frontendUrl` above states the pairing rule and then does not enforce it: the
|
|
271
|
+
* stored `frontend_url` is used whatever backend this invocation was pointed
|
|
272
|
+
* at. For prose that is harmless -- "sign in at ..." wants the dashboard the
|
|
273
|
+
* customer normally uses, and always has one to name. For a *link to a specific
|
|
274
|
+
* run* it is a wrong answer dressed as a right one: point `--backend` at a
|
|
275
|
+
* local API and the run gets a production URL where it does not exist, so the
|
|
276
|
+
* link resolves, loads, and shows nothing. That is worse than no link, because
|
|
277
|
+
* an empty dashboard reads as a lost run rather than a bad address.
|
|
278
|
+
*
|
|
279
|
+
* So this one refuses to guess. An unrecognised backend with no `--frontend`
|
|
280
|
+
* and no matching login returns `""`, and callers omit the link. Saying nothing
|
|
281
|
+
* about where to look is honest; naming somewhere the run has never been is not.
|
|
282
|
+
*/
|
|
283
|
+
export function pairedFrontendUrl(args) {
|
|
284
|
+
const explicit = args.value("--frontend", process.env.PREMAN_FRONTEND || "");
|
|
285
|
+
if (explicit) return explicit.replace(/\/+$/, "");
|
|
286
|
+
|
|
287
|
+
const backend = backendUrl(args);
|
|
288
|
+
const login = activeLogin(args);
|
|
289
|
+
const paired = String(login?.backend_url || "").replace(/\/+$/, "");
|
|
290
|
+
// Only while this invocation is talking to the deployment the key was minted
|
|
291
|
+
// against -- which is exactly the condition `frontendUrl`'s docstring names.
|
|
292
|
+
if (login?.frontend_url && paired && paired === backend) {
|
|
293
|
+
return String(login.frontend_url).replace(/\/+$/, "");
|
|
294
|
+
}
|
|
295
|
+
if (backend === DEFAULT_BACKEND) return DEFAULT_FRONTEND;
|
|
296
|
+
return "";
|
|
297
|
+
}
|
|
298
|
+
|
|
266
299
|
/**
|
|
267
300
|
* Open a URL in the user's default browser, best effort.
|
|
268
301
|
*
|
|
@@ -428,7 +461,7 @@ export async function callBackendJson(
|
|
|
428
461
|
args,
|
|
429
462
|
method,
|
|
430
463
|
routePath,
|
|
431
|
-
{ json, token, query, headers: extraHeaders } = {}
|
|
464
|
+
{ json, form, token, query, headers: extraHeaders } = {}
|
|
432
465
|
) {
|
|
433
466
|
const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
|
|
434
467
|
if (query) {
|
|
@@ -439,6 +472,8 @@ export async function callBackendJson(
|
|
|
439
472
|
|
|
440
473
|
const headers = { Accept: "application/json" };
|
|
441
474
|
const hasBody = json !== undefined && json !== null;
|
|
475
|
+
// No Content-Type for a form: fetch sets it from the FormData, and the
|
|
476
|
+
// multipart boundary it appends is not something a caller can supply.
|
|
442
477
|
if (hasBody) headers["Content-Type"] = "application/json";
|
|
443
478
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
444
479
|
if (extraHeaders) {
|
|
@@ -450,7 +485,7 @@ export async function callBackendJson(
|
|
|
450
485
|
const resp = await fetch(url, {
|
|
451
486
|
method,
|
|
452
487
|
headers,
|
|
453
|
-
body: hasBody ? JSON.stringify(json) : undefined,
|
|
488
|
+
body: form !== undefined && form !== null ? form : hasBody ? JSON.stringify(json) : undefined,
|
|
454
489
|
});
|
|
455
490
|
const text = await resp.text();
|
|
456
491
|
let body = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "premanmcp",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test": "npm run build && npm run test:proxy",
|
|
16
16
|
"test:proxy": "node --test scripts/smoke-proxy.mjs",
|
|
17
17
|
"test:connect": "node scripts/smoke-connect.mjs",
|
|
18
|
-
"test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs",
|
|
18
|
+
"test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-eval.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs",
|
|
19
19
|
"test:dmg": "node --test --test-timeout=300000 scripts/smoke-install-desktop-volume.mjs"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|