@melaya/runner 1.1.68 → 1.1.69

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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Computer control relay: the server's runner socket <-> the computer executor.
3
+ *
4
+ * Path of one command (packages/desktop-contract/src/computer.ts):
5
+ * server dispatchComputerCommand
6
+ * -> socket "computer:command" { id, action, deadlineMs, allowedAppIds, allowedApps } + ack
7
+ * -> this relay -> host channel { type: "computer.command", ... } (desktop app main process)
8
+ * <- { type: "computer.result", id, result } -> ack(result)
9
+ *
10
+ * Who executes:
11
+ * - inside the Melaya desktop app (isHosted()): the app's executor, over the
12
+ * utilityProcess parent port (hostChannel.ts);
13
+ * - plain `npx @melaya/runner --computer` on a headless machine / VM: the CLI
14
+ * computer host (computerHostCli.ts, createCliComputerHost), when enabled;
15
+ * - otherwise: every command is answered `unsupported`.
16
+ *
17
+ * The relay enforces the deadline on its side too: a command that has no
18
+ * result by `deadlineMs` is acked as a timeout, and a late result is dropped.
19
+ * `computer:stop` is forwarded at once. Capabilities are reported on every
20
+ * (re)connect and whenever the executor reports a change.
21
+ *
22
+ * Message types MIRROR the contract (this package cannot import it).
23
+ */
24
+ import { type HostToRunnerMessage, type RunnerToHostMessage } from "./hostChannel.js";
25
+ export declare const COMPUTER_EVENTS: {
26
+ readonly command: "computer:command";
27
+ readonly stop: "computer:stop";
28
+ readonly capabilities: "computer:capabilities";
29
+ /** server -> runner: show a native approval dialog { approvalId, summary, effect, appName?, expiresAt } */
30
+ readonly approval: "computer:approval";
31
+ /** server -> runner: close it (decided elsewhere or expired) { approvalId } */
32
+ readonly approvalClosed: "computer:approval-closed";
33
+ /** runner -> server: the user's answer in the dialog { approvalId, decision } */
34
+ readonly approvalAnswer: "computer:approval-answer";
35
+ };
36
+ export declare const UNSUPPORTED_MESSAGE = "Computer control needs the Melaya desktop app on this computer.";
37
+ /** The parts of a socket.io-client Socket the relay uses (tests pass a fake). */
38
+ export interface RelaySocket {
39
+ connected?: boolean;
40
+ on(event: string, listener: (...args: any[]) => void): unknown;
41
+ emit(event: string, ...args: any[]): unknown;
42
+ }
43
+ /** The computer executor the runner CLI hosts on a headless machine
44
+ * (packages/runner/src/computerHostCli.ts). Results may come back from
45
+ * `handle` or through the `send` callback given at creation. */
46
+ export interface CliComputerHost {
47
+ handle(msg: RunnerToHostMessage): unknown;
48
+ capabilities(): unknown;
49
+ stopAll(reason: string): void;
50
+ dispose(): void;
51
+ }
52
+ export interface ComputerRelayDeps {
53
+ isHosted(): boolean;
54
+ sendToHost(msg: RunnerToHostMessage): void;
55
+ onHostMessage(cb: (msg: HostToRunnerMessage) => void): () => void;
56
+ /** Create the CLI host (null when computer control is not enabled for the CLI). */
57
+ createCliHost(opts: {
58
+ send: (msg: HostToRunnerMessage) => void;
59
+ verbose?: boolean;
60
+ }): Promise<CliComputerHost | null>;
61
+ log(line: string): void;
62
+ }
63
+ /**
64
+ * Attach the relay to the runner's server socket. Call once, right after the
65
+ * socket is created (before it connects). Returns a disposer.
66
+ */
67
+ export declare function attachComputerRelay(socket: RelaySocket, opts?: {
68
+ verbose?: boolean;
69
+ deps?: Partial<ComputerRelayDeps>;
70
+ }): {
71
+ dispose(): void;
72
+ pendingCount(): number;
73
+ };
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Computer control relay: the server's runner socket <-> the computer executor.
3
+ *
4
+ * Path of one command (packages/desktop-contract/src/computer.ts):
5
+ * server dispatchComputerCommand
6
+ * -> socket "computer:command" { id, action, deadlineMs, allowedAppIds, allowedApps } + ack
7
+ * -> this relay -> host channel { type: "computer.command", ... } (desktop app main process)
8
+ * <- { type: "computer.result", id, result } -> ack(result)
9
+ *
10
+ * Who executes:
11
+ * - inside the Melaya desktop app (isHosted()): the app's executor, over the
12
+ * utilityProcess parent port (hostChannel.ts);
13
+ * - plain `npx @melaya/runner --computer` on a headless machine / VM: the CLI
14
+ * computer host (computerHostCli.ts, createCliComputerHost), when enabled;
15
+ * - otherwise: every command is answered `unsupported`.
16
+ *
17
+ * The relay enforces the deadline on its side too: a command that has no
18
+ * result by `deadlineMs` is acked as a timeout, and a late result is dropped.
19
+ * `computer:stop` is forwarded at once. Capabilities are reported on every
20
+ * (re)connect and whenever the executor reports a change.
21
+ *
22
+ * Message types MIRROR the contract (this package cannot import it).
23
+ */
24
+ import { isHosted, onHostMessage, sendToHost } from "./hostChannel.js";
25
+ export const COMPUTER_EVENTS = {
26
+ command: "computer:command",
27
+ stop: "computer:stop",
28
+ capabilities: "computer:capabilities",
29
+ /** server -> runner: show a native approval dialog { approvalId, summary, effect, appName?, expiresAt } */
30
+ approval: "computer:approval",
31
+ /** server -> runner: close it (decided elsewhere or expired) { approvalId } */
32
+ approvalClosed: "computer:approval-closed",
33
+ /** runner -> server: the user's answer in the dialog { approvalId, decision } */
34
+ approvalAnswer: "computer:approval-answer",
35
+ };
36
+ export const UNSUPPORTED_MESSAGE = "Computer control needs the Melaya desktop app on this computer.";
37
+ const MIN_DEADLINE_MS = 1_000;
38
+ const MAX_DEADLINE_MS = 31 * 60_000;
39
+ const MAX_PENDING = 64;
40
+ // The CLI host module is optional (it may be absent from a build): load it by
41
+ // a variable specifier so this file compiles and runs without it.
42
+ const CLI_HOST_MODULE = "./computerHostCli.js";
43
+ const liveDeps = {
44
+ isHosted,
45
+ sendToHost,
46
+ onHostMessage,
47
+ createCliHost: async (opts) => {
48
+ try {
49
+ const m = (await import(CLI_HOST_MODULE));
50
+ return typeof m.createCliComputerHost === "function" ? m.createCliComputerHost(opts) ?? null : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ },
56
+ log: () => { },
57
+ };
58
+ function errorResult(kind, code, message, elapsedMs = 0) {
59
+ return { ok: false, kind: kind || "screen", error: { code, message }, elapsedMs };
60
+ }
61
+ function isResultLike(v) {
62
+ return !!v && typeof v === "object" && ("ok" in v) && ("kind" in v);
63
+ }
64
+ /**
65
+ * Attach the relay to the runner's server socket. Call once, right after the
66
+ * socket is created (before it connects). Returns a disposer.
67
+ */
68
+ export function attachComputerRelay(socket, opts = {}) {
69
+ const deps = { ...liveDeps, ...(opts.deps ?? {}) };
70
+ const log = (line) => { if (opts.verbose)
71
+ deps.log(line); };
72
+ const pending = new Map();
73
+ let lastCapabilities = undefined;
74
+ let cliHost = null;
75
+ let cliHostLoading = null;
76
+ const hosted = deps.isHosted();
77
+ const settle = (id, result) => {
78
+ const p = pending.get(id);
79
+ if (!p)
80
+ return; // late or unknown: the server already got a timeout
81
+ pending.delete(id);
82
+ clearTimeout(p.timer);
83
+ try {
84
+ p.ack(result);
85
+ }
86
+ catch { /* socket gone */ }
87
+ };
88
+ const emitCapabilities = (caps) => {
89
+ lastCapabilities = caps;
90
+ if (socket.connected !== false) {
91
+ try {
92
+ socket.emit(COMPUTER_EVENTS.capabilities, caps ?? null);
93
+ }
94
+ catch { /* reconnect re-sends */ }
95
+ }
96
+ };
97
+ // Executor -> runner (the desktop app, or the CLI host through `send`).
98
+ const onExecutorMessage = (msg) => {
99
+ if (!msg || typeof msg !== "object")
100
+ return;
101
+ // The native approval dialog's answer (contract computer.approval.answer),
102
+ // re-checked field by field: it crossed a process boundary. Only the
103
+ // desktop app has a dialog: the CLI host never sends one.
104
+ const loose = msg;
105
+ if (loose.type === "computer.approval.answer") {
106
+ const approvalId = typeof loose.approvalId === "string" ? loose.approvalId.slice(0, 100) : "";
107
+ const decision = loose.decision === "approve" || loose.decision === "decline" ? loose.decision : null;
108
+ if (hosted && approvalId && decision && socket.connected !== false) {
109
+ try {
110
+ socket.emit(COMPUTER_EVENTS.approvalAnswer, { approvalId, decision });
111
+ }
112
+ catch { /* the web card still decides */ }
113
+ }
114
+ return;
115
+ }
116
+ if (msg.type === "computer.result") {
117
+ const id = typeof msg.id === "string" ? msg.id : "";
118
+ if (id)
119
+ settle(id, msg.result);
120
+ }
121
+ else if (msg.type === "computer.capabilities") {
122
+ emitCapabilities(msg.capabilities ?? null);
123
+ }
124
+ };
125
+ const unsubscribeHost = hosted ? deps.onHostMessage(onExecutorMessage) : () => { };
126
+ const loadCliHost = () => {
127
+ if (hosted)
128
+ return Promise.resolve(null);
129
+ if (cliHost)
130
+ return Promise.resolve(cliHost);
131
+ cliHostLoading ??= deps.createCliHost({ send: onExecutorMessage, verbose: opts.verbose })
132
+ .then((h) => { cliHost = h; return h; })
133
+ .catch(() => null);
134
+ return cliHostLoading;
135
+ };
136
+ const refreshCapabilities = async () => {
137
+ if (hosted) {
138
+ if (lastCapabilities !== undefined)
139
+ emitCapabilities(lastCapabilities);
140
+ deps.sendToHost({ type: "computer.capabilities.request" });
141
+ return;
142
+ }
143
+ const h = await loadCliHost();
144
+ if (!h)
145
+ return; // a plain CLI runner reports nothing: the server sees no computer control
146
+ try {
147
+ emitCapabilities(await h.capabilities());
148
+ }
149
+ catch {
150
+ emitCapabilities(null);
151
+ }
152
+ };
153
+ socket.on("connect", () => { void refreshCapabilities(); });
154
+ socket.on(COMPUTER_EVENTS.command, (payload, ack) => {
155
+ if (typeof ack !== "function")
156
+ return; // fire-and-forget commands are not part of the contract
157
+ const p = (payload && typeof payload === "object" ? payload : {});
158
+ const action = (p.action && typeof p.action === "object" ? p.action : null);
159
+ const kind = typeof action?.kind === "string" ? action.kind : "screen";
160
+ const id = typeof p.id === "string" && p.id ? p.id.slice(0, 100) : "";
161
+ if (!id || !action) {
162
+ ack(errorResult(kind, "invalid_action", "The command was malformed (no id or action)."));
163
+ return;
164
+ }
165
+ if (pending.has(id)) {
166
+ ack(errorResult(kind, "invalid_action", "A command with this id is already running."));
167
+ return;
168
+ }
169
+ if (pending.size >= MAX_PENDING) {
170
+ ack(errorResult(kind, "busy", "Too many computer commands are in flight on this computer."));
171
+ return;
172
+ }
173
+ const deadlineMs = Math.min(MAX_DEADLINE_MS, Math.max(MIN_DEADLINE_MS, Math.floor(Number(p.deadlineMs) || 15_000)));
174
+ const allowedAppIds = Array.isArray(p.allowedAppIds) ? p.allowedAppIds.filter((x) => typeof x === "string") : [];
175
+ const allowedApps = Array.isArray(p.allowedApps) ? p.allowedApps : undefined;
176
+ const msg = {
177
+ type: "computer.command", id, action, deadlineMs, allowedAppIds,
178
+ ...(allowedApps ? { allowedApps } : {}),
179
+ };
180
+ const startedAt = Date.now();
181
+ const timer = setTimeout(() => {
182
+ settle(id, errorResult(kind, "timeout", "The computer did not finish before the deadline. Read the screen before retrying: the action may or may not have happened.", Date.now() - startedAt));
183
+ }, deadlineMs);
184
+ pending.set(id, { ack, kind, startedAt, timer });
185
+ if (hosted) {
186
+ log(` [computer] ${kind} -> desktop app`);
187
+ deps.sendToHost(msg);
188
+ return;
189
+ }
190
+ void loadCliHost().then(async (h) => {
191
+ if (!h) {
192
+ settle(id, errorResult(kind, "unsupported", UNSUPPORTED_MESSAGE));
193
+ return;
194
+ }
195
+ try {
196
+ const out = await h.handle(msg);
197
+ if (out && typeof out === "object" && out.type === "computer.result") {
198
+ settle(id, out.result);
199
+ }
200
+ else if (isResultLike(out)) {
201
+ settle(id, out);
202
+ } // else: the host answers through `send` (onExecutorMessage)
203
+ }
204
+ catch (e) {
205
+ settle(id, errorResult(kind, "helper_unavailable", `The computer helper failed: ${e?.message || e}`, Date.now() - startedAt));
206
+ }
207
+ });
208
+ });
209
+ // Approval dialogs: forwarded to the desktop app only (a CLI host has no UI;
210
+ // the web card and the phone push still carry the approval).
211
+ socket.on(COMPUTER_EVENTS.approval, (payload) => {
212
+ if (!hosted)
213
+ return;
214
+ const p = (payload && typeof payload === "object" ? payload : {});
215
+ const approvalId = typeof p.approvalId === "string" ? p.approvalId.slice(0, 100) : "";
216
+ if (!approvalId)
217
+ return;
218
+ deps.sendToHost({
219
+ type: "computer.approval.request",
220
+ approvalId,
221
+ summary: typeof p.summary === "string" ? p.summary.slice(0, 300) : "An action needs your approval.",
222
+ effect: typeof p.effect === "string" ? p.effect.slice(0, 40) : "message",
223
+ ...(typeof p.appName === "string" && p.appName ? { appName: p.appName.slice(0, 120) } : {}),
224
+ expiresAt: typeof p.expiresAt === "string" ? p.expiresAt : new Date(Date.now() + 15 * 60_000).toISOString(),
225
+ });
226
+ });
227
+ socket.on(COMPUTER_EVENTS.approvalClosed, (payload) => {
228
+ if (!hosted)
229
+ return;
230
+ const approvalId = typeof payload?.approvalId === "string"
231
+ ? String(payload.approvalId).slice(0, 100) : "";
232
+ if (approvalId)
233
+ deps.sendToHost({ type: "computer.approval.closed", approvalId });
234
+ });
235
+ socket.on(COMPUTER_EVENTS.stop, (payload) => {
236
+ const reason = typeof payload?.reason === "string"
237
+ ? String(payload.reason).slice(0, 200)
238
+ : "stop";
239
+ log(` [computer] stop (${reason})`);
240
+ if (hosted)
241
+ deps.sendToHost({ type: "computer.stop", reason });
242
+ else if (cliHost) {
243
+ try {
244
+ cliHost.stopAll(reason);
245
+ }
246
+ catch { /* best effort */ }
247
+ }
248
+ });
249
+ // A dropped socket leaves no one to supervise an action in flight: stop it.
250
+ socket.on("disconnect", () => {
251
+ if (!pending.size)
252
+ return;
253
+ for (const id of [...pending.keys()])
254
+ settle(id, errorResult(pending.get(id).kind, "stopped", "The connection to Melaya dropped."));
255
+ if (hosted)
256
+ deps.sendToHost({ type: "computer.stop", reason: "server_disconnected" });
257
+ else if (cliHost) {
258
+ try {
259
+ cliHost.stopAll("server_disconnected");
260
+ }
261
+ catch { /* best effort */ }
262
+ }
263
+ });
264
+ return {
265
+ dispose() {
266
+ unsubscribeHost();
267
+ for (const p of pending.values())
268
+ clearTimeout(p.timer);
269
+ pending.clear();
270
+ if (cliHost) {
271
+ try {
272
+ cliHost.dispose();
273
+ }
274
+ catch { /* ignore */ }
275
+ cliHost = null;
276
+ }
277
+ },
278
+ pendingCount: () => pending.size,
279
+ };
280
+ }
@@ -14,10 +14,20 @@ import { type DetectedModel } from "./detect.js";
14
14
  interface ConnectOpts {
15
15
  token: string;
16
16
  serverUrl: string;
17
- pythonPath: string;
17
+ /** The interpreter, or a promise of it: the socket connects before Python is
18
+ * resolved (cli.ts). null = no usable Python (the CLI then exits). */
19
+ pythonPath: string | Promise<string | null>;
20
+ /** Initial list (last good from disk, or empty); fresh discovery arrives via publishModels. */
18
21
  models: DetectedModel[];
19
22
  verbose: boolean;
20
23
  }
21
- export declare function connect(opts: ConnectOpts): Promise<void>;
24
+ /** What cli.ts uses after connect(): publish fresh discovery, print around the spinner. */
25
+ export interface RunnerConnection {
26
+ /** Replace the model list and push it through `runner:models` when it changed. */
27
+ publishModels(models: DetectedModel[]): void;
28
+ /** console.log that keeps the connect spinner intact. */
29
+ print(line: string): void;
30
+ }
31
+ export declare function connect(opts: ConnectOpts): Promise<RunnerConnection>;
22
32
  export declare function verifySignature(content: string, signature: string, runnerToken: string): boolean;
23
33
  export {};