@melaya/runner 1.1.76 → 1.1.77

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.
@@ -1,6 +1,12 @@
1
1
  import type { BrowserGrant } from "./browserGrantVerify.js";
2
2
  import { type BrowserSessionRecord, type SpaceSpec } from "./sessionManager.js";
3
3
  import { type BrowserEngineId } from "./browserProvisioner.js";
4
+ export declare const BATCH_MAX_STEPS = 5000;
5
+ /** browser.py holds a batch call 165 s: a batch still going at 150 s stops
6
+ * BEFORE its next step and says which steps ran, instead of running on after
7
+ * the caller gave up (a long batch takes minutes at ~1 s a step). The runner
8
+ * has no background run for its own browser: 150 s is this call's real limit. */
9
+ export declare const BATCH_TIME_BUDGET_MS = 150000;
4
10
  export interface BrowserRunSpec {
5
11
  runId: string;
6
12
  grant: BrowserGrant;
@@ -436,10 +436,16 @@ function buildDragPath(from, to, steps, moveMs, shape) {
436
436
  out[out.length - 1] = { x: to.x, y: to.y, dwellMs: last.dwellMs };
437
437
  return out;
438
438
  }
439
- // Mirrors shared/tools/browser.py's _BATCH_ALLOWED_KINDS / _BATCH_MAX_STEPS.
440
- // The tool layer validates too; this is the enforcement point, because the
441
- // bridge must not trust a caller that skipped it.
442
- const BATCH_MAX_STEPS = 10;
439
+ // Mirrors shared/tools/browser.py's _BATCH_ALLOWED_KINDS / _BATCH_MAX_STEPS (5000,
440
+ // the server's BROWSER_BATCH_MAX_STEPS, 2026-10-28). The tool layer validates
441
+ // too; this is the enforcement point, because the bridge must not trust a
442
+ // caller that skipped it.
443
+ export const BATCH_MAX_STEPS = 5000;
444
+ /** browser.py holds a batch call 165 s: a batch still going at 150 s stops
445
+ * BEFORE its next step and says which steps ran, instead of running on after
446
+ * the caller gave up (a long batch takes minutes at ~1 s a step). The runner
447
+ * has no background run for its own browser: 150 s is this call's real limit. */
448
+ export const BATCH_TIME_BUDGET_MS = 150_000;
443
449
  const BATCH_ALLOWED_KINDS = new Set([
444
450
  "navigate", "back", "forward", "click", "tap", "input_text", "press_key",
445
451
  "scroll", "select_option", "submit", "wait", "wait_for_network_idle",
@@ -1457,7 +1463,17 @@ export async function startBrowserBridge(opts) {
1457
1463
  throw new BridgeError("act_args_invalid", `batch takes at most ${BATCH_MAX_STEPS} steps; split the flow into several batches`);
1458
1464
  }
1459
1465
  const done = [];
1466
+ const batchStarted = Date.now();
1460
1467
  for (let i = 0; i < rawSteps.length; i++) {
1468
+ if (i > 0 && Date.now() - batchStarted >= BATCH_TIME_BUDGET_MS) {
1469
+ // Out of time: nothing more runs, and the caller learns exactly where it stopped.
1470
+ return {
1471
+ batch: true, steps_run: done.length, steps: done, stopped_at_time_limit: true,
1472
+ note: `The batch stopped at the ${BATCH_TIME_BUDGET_MS / 1000} s limit of one call: steps 1-${done.length} of ` +
1473
+ `${rawSteps.length} ran, steps ${done.length + 1}-${rawSteps.length} were NOT run. Read the page, then send ` +
1474
+ "the remaining steps in another browser_batch call.",
1475
+ };
1476
+ }
1461
1477
  const raw = rawSteps[i];
1462
1478
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1463
1479
  throw new BridgeError("act_args_invalid", `batch step ${i} is not an object`);
@@ -15,7 +15,7 @@ import chalk from "chalk";
15
15
  import ora from "ora";
16
16
  // Every child spawned here leads its own process group (POSIX) and is tracked,
17
17
  // so shutdown and runner:kill stop the whole tree (audit P0.5).
18
- import { spawnTracked as spawn, terminateTree, terminateAllTracked, runShutdownSteps, formatShutdownResults, claimGracefulShutdown, killAllTrackedSync, setSetupBackground, lowerPriority, } from "./processTree.js";
18
+ import { spawnTracked as spawn, terminateTree, terminateTreeVerified, terminateAllTracked, runShutdownSteps, formatShutdownResults, claimGracefulShutdown, killAllTrackedSync, setSetupBackground, lowerPriority, } from "./processTree.js";
19
19
  import { markStage } from "./startupTimings.js";
20
20
  import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
21
21
  import { dirname, isAbsolute, join } from "path";
@@ -51,7 +51,7 @@ import { attachPlanSocketHandlers } from "./stepPlans.js";
51
51
  // Local REST API); the REST key stays in ~/.melaya-runner/vaults.
52
52
  import { ObsidianAppBridge } from "./obsidianApp.js";
53
53
  // Desktop app host channel: state changes for the app's tray and web UI (no-op under npx).
54
- import { hostStatus, isHosted, hostProgress, sendToHost, hostAppVersion } from "./hostChannel.js";
54
+ import { hostStatus, isHosted, hostProgress, sendToHost, hostAppVersion, hostRunnerSource, startHostActivityReporter } from "./hostChannel.js";
55
55
  // Desktop app: files this runner wrote are offered to computer control as handles (no-op under npx).
56
56
  import { reportHostFile, reportOutputsSince } from "./hostFiles.js";
57
57
  // Computer control: computer:command / computer:stop relayed to the desktop app
@@ -85,6 +85,8 @@ const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
85
85
  const EVENT_TRIGGERS_CAPABILITY = "event_triggers_v1";
86
86
  const HEARTBEAT_INTERVAL = 30_000;
87
87
  const activeProcesses = new Map();
88
+ // Runs the server cancelled (runner:kill): their exit reports "canceled".
89
+ const cancelledRunIds = new Set();
88
90
  // Melaya Browser: the versioned capability advertised in the runner hello
89
91
  // (plan Section 4). Bumped when the browser wire contract changes.
90
92
  const BROWSER_CONTROL_VERSION = 1;
@@ -483,6 +485,9 @@ export async function connect(opts) {
483
485
  // request needs the runtime first, it does the work itself and this never
484
486
  // runs (the request path already ensured the same things).
485
487
  const _runtimeIdle = () => activeProcesses.size === 0 && activeAssistants.size === 0 && bootingAssistants.size === 0;
488
+ // Desktop app: busy / idle on change, so a newer downloaded runner replaces
489
+ // this one only while nothing runs (no-op outside the app).
490
+ startHostActivityReporter(() => !_runtimeIdle());
486
491
  let bgTimer = null;
487
492
  const scheduleBackgroundSetup = () => {
488
493
  if (bgSetup || bgTimer || foregroundDemand)
@@ -626,7 +631,8 @@ export async function connect(opts) {
626
631
  // the server keeps that computer's app version current on every start
627
632
  // (the token row is minted once) and knows this runner updates with the
628
633
  // app, never with a restart command.
629
- ...(isHosted() ? { host: { kind: "desktop", ...(hostAppVersion() ? { appVersion: hostAppVersion() } : {}) } } : {}),
634
+ // runnerSource: the app's bundled runner or a signed runner bundle it downloaded.
635
+ ...(isHosted() ? { host: { kind: "desktop", ...(hostAppVersion() ? { appVersion: hostAppVersion() } : {}), ...(hostRunnerSource() ? { runnerSource: hostRunnerSource() } : {}) } } : {}),
630
636
  });
631
637
  // Index statuses produced while disconnected (or before this socket).
632
638
  for (const ev of vaultService.latestStatuses())
@@ -867,6 +873,21 @@ export async function connect(opts) {
867
873
  socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
868
874
  return;
869
875
  }
876
+ // Register the run in the DB + set the active project for event relay
877
+ // FIRST, before any pre-start work (browser grant, shared modules, Python
878
+ // env bootstrap, model preflight). The server drops every event of a run
879
+ // that is not registered: registering after the env bootstrap made its
880
+ // progress invisible and a bootstrap failure (its message and
881
+ // runComplete) vanish without a trace (Mac runner, prod 2026-10-27).
882
+ setActiveProject(payload.project);
883
+ socket.emit("runner:registerRun", {
884
+ runId: payload.runId,
885
+ project: payload.project,
886
+ pipelineName: payload.pipelineName,
887
+ ...(typeof payload.openedFrom === "string" && /^[a-z][a-z_]{0,15}$/.test(payload.openedFrom)
888
+ ? { openedFrom: payload.openedFrom }
889
+ : {}),
890
+ });
870
891
  // ── Melaya Browser grant verification + bridge start ──────────────
871
892
  // (plan Section 4) A browser run carries a signed browserGrant. We
872
893
  // VERIFY it BEFORE any Python spawns and reject on ANY failure. The
@@ -987,16 +1008,7 @@ export async function connect(opts) {
987
1008
  }
988
1009
  // Replace the system python with the venv python for THIS spawn.
989
1010
  const pipelinePython = envResult.pythonPath;
990
- // Register run in DB + set active project for event relay
991
- setActiveProject(payload.project);
992
- socket.emit("runner:registerRun", {
993
- runId: payload.runId,
994
- project: payload.project,
995
- pipelineName: payload.pipelineName,
996
- ...(typeof payload.openedFrom === "string" && /^[a-z][a-z_]{0,15}$/.test(payload.openedFrom)
997
- ? { openedFrom: payload.openedFrom }
998
- : {}),
999
- });
1011
+ // (runner:registerRun was sent at the top of this handler.)
1000
1012
  // Write main.py to temp dir
1001
1013
  const runDir = join(tmpdir(), `melaya-run-${payload.runId}`);
1002
1014
  mkdirSync(runDir, { recursive: true });
@@ -1372,7 +1384,8 @@ export async function connect(opts) {
1372
1384
  if (browserBridge && browserBridge.hasRun(payload.runId)) {
1373
1385
  browserBridge.teardownRun(payload.runId, `run_exit:${code}`).catch(() => { });
1374
1386
  }
1375
- const status = code === 0 ? "done" : "failed";
1387
+ // A run the server cancelled reports "canceled", whatever its exit code.
1388
+ const status = cancelledRunIds.delete(payload.runId) ? "canceled" : code === 0 ? "done" : "failed";
1376
1389
  // On non-zero exit, relay the captured stderr tail to the FE
1377
1390
  // as a system agent_message so the operator sees the actual
1378
1391
  // failure reason. Without this, a Python crash before
@@ -2118,6 +2131,15 @@ export async function connect(opts) {
2118
2131
  _spareBundle = v;
2119
2132
  }).catch(() => { });
2120
2133
  };
2134
+ // Desktop app only: the server announced a signed runner bundle. The app
2135
+ // re-reads GET /api/v1/desktop/config itself (the one source); this only
2136
+ // says "look now" instead of at the next 6-hourly check.
2137
+ socket.on("runner:bundle", (p) => {
2138
+ if (!isHosted())
2139
+ return;
2140
+ const v = typeof p?.version === "string" && /^\d{1,4}\.\d{1,5}\.\d{1,6}$/.test(p.version) ? p.version : undefined;
2141
+ sendToHost({ type: "bundle_hint", ...(v ? { version: v } : {}) });
2142
+ });
2121
2143
  // Server push: download a new shared bundle in the background (after an
2122
2144
  // agents deploy, and on connect) so the next chat does not pay for it.
2123
2145
  socket.on("runner:prefetch_bundle", (p) => {
@@ -3318,7 +3340,15 @@ export async function connect(opts) {
3318
3340
  const proc = activeProcesses.get(data.runId);
3319
3341
  if (proc) {
3320
3342
  // The whole tree: a run's own children (CLI providers, browsers) too.
3321
- void terminateTree(proc, { graceMs: SHUTDOWN_CHILD_GRACE_MS });
3343
+ // Verified: a kill that did not take is retried and reported (run
3344
+ // 939e9d3f6ddb4c45 logged "Killed run" and kept running to the end).
3345
+ cancelledRunIds.add(data.runId);
3346
+ const runId = data.runId;
3347
+ void terminateTreeVerified(proc, { graceMs: SHUTDOWN_CHILD_GRACE_MS }).then((gone) => {
3348
+ if (!gone) {
3349
+ console.log(chalk.red(` ✗ Run ${runId.slice(0, 10)}... is still running after the kill; it stops at its next approval check.`));
3350
+ }
3351
+ });
3322
3352
  activeProcesses.delete(data.runId);
3323
3353
  console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
3324
3354
  }
@@ -14,6 +14,22 @@
14
14
  * build (tsc, rootDir src) cannot import that package, so keep both in step.
15
15
  */
16
16
  export type HostedRunnerState = "starting" | "online" | "reconnecting" | "blocked" | "error";
17
+ /**
18
+ * Host channel wire version this runner speaks. A signed runner bundle declares
19
+ * it (manifest.hostProtocol, read from dist/hostChannel.js by
20
+ * desktop/scripts/runner-bundle.mjs) and the desktop app runs the bundle only
21
+ * when it supports it. Mirror: RUNNER_HOST_PROTOCOL in packages/desktop-contract.
22
+ * Bump only on a breaking change of the messages below.
23
+ */
24
+ export declare const HOST_PROTOCOL = 1;
25
+ /**
26
+ * Oldest desktop app that may run this runner as a downloaded bundle
27
+ * (manifest.minAppVersion). 0.1.7 is the first app with the bundle loader;
28
+ * raise it when this runner needs something only a newer app provides.
29
+ */
30
+ export declare const MIN_HOST_APP_VERSION = "0.1.7";
31
+ /** Where the desktop app took this runner from (its start message). */
32
+ export type HostRunnerSource = "bundled" | "downloaded";
17
33
  /** Why a terminal "error" happened, so the app can react without parsing text. */
18
34
  export type HostedStatusCode = "token_rejected" | "server_disconnect";
19
35
  /** Mirror of the contract's RunnerToHostMessage (computer messages kept loose). */
@@ -80,16 +96,28 @@ export type RunnerToHostMessage = {
80
96
  | {
81
97
  type: "notice";
82
98
  code: "browser_not_installed";
99
+ }
100
+ /** Additive: work in flight (runs, Assistant hosts), sent on change; the app swaps runners only while idle. */
101
+ | {
102
+ type: "activity";
103
+ busy: boolean;
104
+ }
105
+ /** Additive: the server announced a runner bundle (runner:bundle); the app re-reads its config. */
106
+ | {
107
+ type: "bundle_hint";
108
+ version?: string;
83
109
  };
84
110
  /** Mirror of the contract's HostToRunnerMessage (computer messages kept loose). */
85
111
  export type HostToRunnerMessage = {
86
112
  type: "stop";
87
113
  }
88
- /** `appVersion`: the desktop app's own version (additive; reported in runner:hello's `host` block). */
114
+ /** `appVersion`: the desktop app's own version; `runnerSource`: bundled or downloaded
115
+ * (both additive; reported in runner:hello's `host` block). */
89
116
  | {
90
117
  type: "start";
91
118
  token: string;
92
119
  appVersion?: string;
120
+ runnerSource?: HostRunnerSource;
93
121
  }
94
122
  /** Additive: rescan model providers now and publish the list through runner:models. */
95
123
  | {
@@ -118,6 +146,15 @@ export declare function redactReason(s: string): string;
118
146
  export declare function hostStatus(state: HostedRunnerState, lastError?: string, code?: HostedStatusCode): void;
119
147
  /** Background setup progress for the desktop app. No-op outside the app. */
120
148
  export declare function hostProgress(state: "running" | "done" | "failed", step?: string, waiting?: boolean): void;
149
+ /** bundled | downloaded, as the desktop app's start message said; null outside
150
+ * the app and with an app older than the field. */
151
+ export declare function hostRunnerSource(): HostRunnerSource | null;
152
+ /**
153
+ * Reports busy / idle to the desktop app whenever it changes (checked every
154
+ * `intervalMs`), so the app swaps to a newer downloaded runner only while
155
+ * nothing runs. No-op outside the app. Returns a stop function.
156
+ */
157
+ export declare function startHostActivityReporter(isBusy: () => boolean, intervalMs?: number): () => void;
121
158
  /** The desktop app's own version, as its start message said; null outside the
122
159
  * app and with an app older than the field. */
123
160
  export declare function hostAppVersion(): string | null;
@@ -13,6 +13,20 @@
13
13
  * (RunnerToHostMessage / HostToRunnerMessage) and src/computer.ts. The runner
14
14
  * build (tsc, rootDir src) cannot import that package, so keep both in step.
15
15
  */
16
+ /**
17
+ * Host channel wire version this runner speaks. A signed runner bundle declares
18
+ * it (manifest.hostProtocol, read from dist/hostChannel.js by
19
+ * desktop/scripts/runner-bundle.mjs) and the desktop app runs the bundle only
20
+ * when it supports it. Mirror: RUNNER_HOST_PROTOCOL in packages/desktop-contract.
21
+ * Bump only on a breaking change of the messages below.
22
+ */
23
+ export const HOST_PROTOCOL = 1;
24
+ /**
25
+ * Oldest desktop app that may run this runner as a downloaded bundle
26
+ * (manifest.minAppVersion). 0.1.7 is the first app with the bundle loader;
27
+ * raise it when this runner needs something only a newer app provides.
28
+ */
29
+ export const MIN_HOST_APP_VERSION = "0.1.7";
16
30
  function parentPort() {
17
31
  const p = process.parentPort;
18
32
  return p && typeof p.postMessage === "function" ? p : null;
@@ -89,6 +103,39 @@ export function hostProgress(state, step, waiting) {
89
103
  });
90
104
  }
91
105
  let _hostAppVersion = null;
106
+ let _hostRunnerSource = null;
107
+ /** bundled | downloaded, as the desktop app's start message said; null outside
108
+ * the app and with an app older than the field. */
109
+ export function hostRunnerSource() {
110
+ return _hostRunnerSource;
111
+ }
112
+ /**
113
+ * Reports busy / idle to the desktop app whenever it changes (checked every
114
+ * `intervalMs`), so the app swaps to a newer downloaded runner only while
115
+ * nothing runs. No-op outside the app. Returns a stop function.
116
+ */
117
+ export function startHostActivityReporter(isBusy, intervalMs = 5000) {
118
+ if (!isHosted())
119
+ return () => { };
120
+ let last = null;
121
+ const tick = () => {
122
+ let busy = true;
123
+ try {
124
+ busy = isBusy();
125
+ }
126
+ catch {
127
+ busy = true;
128
+ }
129
+ if (busy === last)
130
+ return;
131
+ last = busy;
132
+ sendToHost({ type: "activity", busy });
133
+ };
134
+ tick();
135
+ const t = setInterval(tick, intervalMs);
136
+ t.unref?.();
137
+ return () => clearInterval(t);
138
+ }
92
139
  /** The desktop app's own version, as its start message said; null outside the
93
140
  * app and with an app older than the field. */
94
141
  export function hostAppVersion() {
@@ -110,6 +157,8 @@ export function waitForHostToken(timeoutMs = 30_000) {
110
157
  return;
111
158
  const appVersion = msg.appVersion;
112
159
  _hostAppVersion = typeof appVersion === "string" && /^[0-9A-Za-z.+-]{1,32}$/.test(appVersion) ? appVersion : null;
160
+ const source = msg.runnerSource;
161
+ _hostRunnerSource = source === "bundled" || source === "downloaded" ? source : null;
113
162
  clearTimeout(timer);
114
163
  off();
115
164
  resolve(token);
@@ -27,11 +27,23 @@ export declare const spawnTracked: typeof rawSpawn;
27
27
  /**
28
28
  * Stop a child and every descendant: a graceful request first, then a forced
29
29
  * kill once `graceMs` has passed. Resolves when the tree is gone (or the forced
30
- * kill was issued). Never throws.
30
+ * kill was issued), with whether the child itself has exited. Never throws.
31
31
  */
32
32
  export declare function terminateTree(child: ChildProcess, opts?: {
33
33
  graceMs?: number;
34
- }): Promise<void>;
34
+ }): Promise<boolean>;
35
+ /**
36
+ * Stop a run's process tree and make sure it is gone: terminateTree, then a
37
+ * forced tree kill again, then a direct kill of the child (on Windows the venv
38
+ * launcher kills its interpreter with it). Run 939e9d3f6ddb4c45 was cancelled,
39
+ * the runner logged "Killed run", yet the process kept running for 9 minutes
40
+ * and finished: a single unchecked kill is not enough. Resolves with whether
41
+ * the child has exited. Never throws.
42
+ */
43
+ export declare function terminateTreeVerified(child: ChildProcess, opts?: {
44
+ graceMs?: number;
45
+ terminate?: typeof terminateTree;
46
+ }): Promise<boolean>;
35
47
  /** Stop every tracked child tree, in parallel, within the grace period. */
36
48
  export declare function terminateAllTracked(opts?: {
37
49
  graceMs?: number;
@@ -96,24 +96,24 @@ function waitExit(child, ms) {
96
96
  /**
97
97
  * Stop a child and every descendant: a graceful request first, then a forced
98
98
  * kill once `graceMs` has passed. Resolves when the tree is gone (or the forced
99
- * kill was issued). Never throws.
99
+ * kill was issued), with whether the child itself has exited. Never throws.
100
100
  */
101
101
  export async function terminateTree(child, opts = {}) {
102
102
  const graceMs = Math.max(0, opts.graceMs ?? 2000);
103
103
  const pid = child?.pid;
104
104
  if (typeof pid !== "number")
105
- return;
105
+ return true;
106
106
  if (isWin) {
107
107
  if (alive(child)) {
108
108
  if (graceMs > 0) {
109
109
  await taskkill(pid, false);
110
110
  if (await waitExit(child, graceMs))
111
- return;
111
+ return true;
112
112
  }
113
113
  await taskkill(pid, true);
114
114
  await waitExit(child, 1000);
115
115
  }
116
- return;
116
+ return !alive(child);
117
117
  }
118
118
  if (graceMs > 0 && alive(child)) {
119
119
  signalTree(child, "SIGTERM");
@@ -131,6 +131,30 @@ export async function terminateTree(child, opts = {}) {
131
131
  catch { /* gone */ }
132
132
  await waitExit(child, 1000);
133
133
  }
134
+ return !alive(child);
135
+ }
136
+ /**
137
+ * Stop a run's process tree and make sure it is gone: terminateTree, then a
138
+ * forced tree kill again, then a direct kill of the child (on Windows the venv
139
+ * launcher kills its interpreter with it). Run 939e9d3f6ddb4c45 was cancelled,
140
+ * the runner logged "Killed run", yet the process kept running for 9 minutes
141
+ * and finished: a single unchecked kill is not enough. Resolves with whether
142
+ * the child has exited. Never throws.
143
+ */
144
+ export async function terminateTreeVerified(child, opts = {}) {
145
+ const terminate = opts.terminate ?? terminateTree;
146
+ if (await terminate(child, { graceMs: opts.graceMs }))
147
+ return true;
148
+ if (await terminate(child, { graceMs: 0 }))
149
+ return true;
150
+ if (alive(child)) {
151
+ try {
152
+ child.kill("SIGKILL");
153
+ }
154
+ catch { /* gone */ }
155
+ await waitExit(child, 2000);
156
+ }
157
+ return !alive(child);
134
158
  }
135
159
  /** Stop every tracked child tree, in parallel, within the grace period. */
136
160
  export async function terminateAllTracked(opts = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.76",
3
+ "version": "1.1.77",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",