@addai/node 0.24.1 → 0.25.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.
@@ -20,6 +20,23 @@ interface BinaryProbe extends AuthShape {
20
20
  /** All remote login methods (primary strategy first, then alternates). */
21
21
  login_methods?: string[];
22
22
  }
23
+ interface FlowsCapability {
24
+ /** Whether this machine is claiming flow runs. Mirrors the server's switch;
25
+ * reported back so the Studio can show agreement rather than intent. */
26
+ enabled: boolean;
27
+ /** Version of the shared engine this daemon bundles. */
28
+ engine_version: string | null;
29
+ /** 'isolated-vm' or the weaker 'vm' fallback, stated plainly. */
30
+ sandbox: {
31
+ kind: string;
32
+ isolated: boolean;
33
+ note: string | null;
34
+ };
35
+ /** Flow runs in flight, and the ceiling. Its own pool — separate from
36
+ * agent runs on purpose, so a flows burst cannot starve a chat. */
37
+ running: number;
38
+ max_concurrent: number;
39
+ }
23
40
  interface CapabilitiesShape {
24
41
  /** Version of @addai/node running. Lets the studio / CodeFlows
25
42
  * UIs show "outdated daemon" hints and helps us debug field issues
@@ -49,6 +66,12 @@ interface CapabilitiesShape {
49
66
  * installed. Studio renders an install card in place of the create
50
67
  * button when this is null. */
51
68
  container_engine?: EngineInfo | null;
69
+ /** What this machine can do about +Ai Flows, and what it is doing right now.
70
+ * The Studio's Flows tab is built entirely out of this — whether the pump
71
+ * is on, how much room is left, and crucially which sandbox the engine
72
+ * resolved, because a UI that implies isolated-vm when the weaker `vm`
73
+ * fallback is running is worse than one that says nothing. */
74
+ flows?: FlowsCapability;
52
75
  /** CPU / memory / disk / runs at the moment of this probe. The AiNode page
53
76
  * has read this key since it was written; nothing produced it until now. */
54
77
  machine?: MachineSample;
@@ -56,6 +56,8 @@ const harness_registry_1 = require("./harness-registry");
56
56
  const autostart_1 = require("./autostart");
57
57
  const auto_update_1 = require("./auto-update");
58
58
  const machine_metrics_1 = require("./machine-metrics");
59
+ const pump_1 = require("./flows/pump");
60
+ const engine_loader_1 = require("./flows/engine-loader");
59
61
  function readDaemonVersion() {
60
62
  try {
61
63
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -368,6 +370,51 @@ async function probeGit() {
368
370
  authed: true,
369
371
  };
370
372
  }
373
+ /** Describe this machine's flows support.
374
+ *
375
+ * Deliberately does NOT force the engine to load. The engine is ESM with a
376
+ * top-level probe of the optional native sandbox, and loading it on every
377
+ * heartbeat — on machines that never run a flow — would be a cost paid by
378
+ * everyone for the benefit of a few. Once flows are on and the engine is
379
+ * loaded, the real sandbox is reported; until then it says so honestly
380
+ * rather than guessing.
381
+ *
382
+ * Never throws: a capabilities probe that could fail would take the whole
383
+ * heartbeat with it, and the machine would read offline because it could not
384
+ * describe its flows support.
385
+ */
386
+ async function probeFlows() {
387
+ let engineVersion = null;
388
+ try {
389
+ // JSON, so plain require is safe here — this is not the ESM graph.
390
+ engineVersion = require('@addai/node-flows/package.json').version ?? null;
391
+ }
392
+ catch { /* not installed; reported as null below */ }
393
+ let sandbox = {
394
+ kind: 'not-loaded',
395
+ isolated: false,
396
+ note: 'The engine loads when flows are switched on for this machine.',
397
+ };
398
+ if ((0, engine_loader_1.engineLoaded)()) {
399
+ try {
400
+ const { sandboxAvailability } = await (0, engine_loader_1.loadEngine)();
401
+ sandbox = sandboxAvailability();
402
+ }
403
+ catch (err) {
404
+ sandbox = {
405
+ kind: 'unavailable', isolated: false,
406
+ note: `The flows engine failed to load: ${err.message}`,
407
+ };
408
+ }
409
+ }
410
+ return {
411
+ enabled: (0, pump_1.flowsEnabled)(),
412
+ engine_version: engineVersion,
413
+ sandbox,
414
+ running: (0, pump_1.inflightFlowCount)(),
415
+ max_concurrent: (0, pump_1.maxConcurrentFlows)(),
416
+ };
417
+ }
371
418
  async function probeCapabilities() {
372
419
  // Binary lookups cache misses; a CLI installed mid-daemon-life (by the
373
420
  // command runner or by hand) must show up on the next probe, not after
@@ -403,6 +450,7 @@ async function probeCapabilities() {
403
450
  daemon_version: readDaemonVersion(),
404
451
  autostart: (0, autostart_1.status)(),
405
452
  container_engine: containerEngine,
453
+ flows: await probeFlows(),
406
454
  auto_update: (0, auto_update_1.autoUpdateState)(),
407
455
  machine: (0, machine_metrics_1.sampleMachine)(),
408
456
  claude: deco('claude', claude),
@@ -52,7 +52,6 @@ const codex_binary_1 = require("./codex-binary");
52
52
  const diskguard_1 = require("./diskguard");
53
53
  const win_1 = require("./win");
54
54
  const events_1 = require("./events");
55
- const mcp_headers_1 = require("./mcp-headers");
56
55
  /** Quote a string as a TOML basic string (double-quoted, escape backslash + quote). */
57
56
  function tomlString(s) {
58
57
  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
@@ -61,11 +60,6 @@ function tomlString(s) {
61
60
  * Only the NAME goes in config.toml — the value rides on the child env, so
62
61
  * the token never lands on disk. */
63
62
  const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
64
- /** Env var name carrying one HTTP header's value into the codex process.
65
- * config.toml references it by name via env_http_headers, so the value
66
- * itself never lands on disk. */
67
- const headerEnvVar = (slug, header) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}` +
68
- `_H_${header.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`;
69
63
  /** Build a config.toml that declares only the supplied MCP servers and
70
64
  * return the CODEX_HOME directory plus any env vars the child needs
71
65
  * (bearer tokens for remote servers). Caller is responsible for cleanup
@@ -80,16 +74,12 @@ function writeCodexHome(servers, workingDirectory) {
80
74
  // Remote MCP servers. Registry rows store command='http'|'sse' with
81
75
  // args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
82
76
  // the row verbatim made codex try to exec a binary called `http`, so the
83
- // server never started and its tools silently vanished.
84
- //
85
- // Credentials go through env_http_headers, which maps a header name to the
86
- // NAME of an env var holding its value — so the whole credential set is
87
- // forwarded and config.toml still never contains a secret. This adapter
88
- // used to send only ONE credential as a bearer token, on the belief that
89
- // codex could not send custom headers; it can, and a server needing more
90
- // than one header (addai-drive carries three) was arriving half-authorised.
91
- if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
92
- const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
77
+ // server never started and its tools silently vanished — which is how a
78
+ // provider-ladder hop to codex dropped an entity's GitHub access without
79
+ // any error anywhere. The claude path (install.ts) always handled this;
80
+ // codex and grok did not.
81
+ if (s.command === 'http' || s.command === 'sse') {
82
+ const url = Array.isArray(s.args) && typeof s.args[0] === 'string' ? s.args[0] : null;
93
83
  if (!url) {
94
84
  console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
95
85
  continue;
@@ -97,24 +87,22 @@ function writeCodexHome(servers, workingDirectory) {
97
87
  const name = tomlString(s.slug).slice(1, -1);
98
88
  toml += `[mcp_servers.${name}]\n`;
99
89
  toml += `url = ${tomlString(url)}\n`;
100
- const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
101
- // An OAuth access token is spelled as codex's native bearer rather than
102
- // a hand-rolled Authorization header, so its OAuth handling still applies.
103
- const bearer = headers.Authorization;
90
+ // codex reads the bearer from its OWN environment (--env is stdio-only),
91
+ // so the value goes on the child env and only the var name in the file.
92
+ const creds = Object.entries(s.env || {})
93
+ .filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
94
+ const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
104
95
  if (bearer) {
105
- delete headers.Authorization;
106
96
  const varName = bearerEnvVar(s.slug);
107
- extraEnv[varName] = bearer.replace(/^Bearer\s+/i, '');
97
+ extraEnv[varName] = String(bearer[1]);
108
98
  toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
109
- }
110
- const entries = Object.entries(headers);
111
- if (entries.length > 0) {
112
- // Sub-table must follow the parent table's scalar keys.
113
- toml += `[mcp_servers.${name}.env_http_headers]\n`;
114
- for (const [header, value] of entries) {
115
- const varName = headerEnvVar(s.slug, header);
116
- extraEnv[varName] = value;
117
- toml += `${tomlString(header)} = ${tomlString(varName)}\n`;
99
+ if (creds.length > 1) {
100
+ // codex carries one bearer and no arbitrary headers. Anything that
101
+ // authenticates with several custom headers is only partly wired,
102
+ // and saying so beats a server that 401s for no visible reason.
103
+ console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
104
+ `codex cannot send custom headers, so ${creds.length - 1} other credential ` +
105
+ `field(s) were dropped`);
118
106
  }
119
107
  }
120
108
  toml += '\n';
@@ -577,6 +577,7 @@ async function runSetAutostart(cmd) {
577
577
  const DESKTOP_KINDS = [
578
578
  'desktop_create', 'desktop_start', 'desktop_stop',
579
579
  'desktop_delete', 'desktop_rebuild', 'desktop_sync_logins',
580
+ 'desktop_exec',
580
581
  ];
581
582
  /** Setup scripts run as a FILE, never as a `sh -c` string: a script with
582
583
  * quotes or newlines must not need escaping to survive. */
@@ -592,7 +593,12 @@ async function runDesktopCommand(cmd) {
592
593
  }
593
594
  // force: a user who just installed Docker should not have to restart the
594
595
  // daemon for their first desktop to build.
595
- const provider = await (0, docker_1.getProvider)(true);
596
+ // Re-detect the engine for the RARE lifecycle commands: someone who has
597
+ // just installed Docker should not have to restart the daemon for a create
598
+ // or a start to find it. desktop_exec is the opposite — an entity driving a
599
+ // remote desktop makes dozens of these, and `docker info` costs seconds on
600
+ // Docker Desktop (measured: 4.2s on a Mac), so it uses the cached engine.
601
+ const provider = await (0, docker_1.getProvider)(cmd.kind !== 'desktop_exec');
596
602
  if (!provider) {
597
603
  const why = 'No container engine on this machine. Install Docker Desktop or Podman, then try again.';
598
604
  await update(cmd.id, 'failed', undefined, why);
@@ -650,11 +656,18 @@ async function runDesktopCommand(cmd) {
650
656
  }
651
657
  break;
652
658
  }
653
- case 'desktop_start':
659
+ case 'desktop_start': {
660
+ // Someone pressed start, so this one fails loudly rather than
661
+ // deferring quietly the way autostart does. They are owed an answer
662
+ // they can act on: which limit, and where to change it.
663
+ const room = await (0, manager_1.desktopStartAllowed)(await (0, manager_1.listDesktops)(), row.id);
664
+ if (!room.allowed)
665
+ throw new Error(room.reason);
654
666
  await (0, manager_1.setStatus)(row.id, { status: 'starting' });
655
667
  await provider.start(row);
656
668
  await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
657
669
  break;
670
+ }
658
671
  case 'desktop_stop':
659
672
  await provider.stop(row);
660
673
  await (0, manager_1.setStatus)(row.id, { status: 'stopped', status_message: null });
@@ -671,6 +684,26 @@ async function runDesktopCommand(cmd) {
671
684
  case 'desktop_sync_logins':
672
685
  await (0, creds_1.syncCredentials)(provider, row, onLog);
673
686
  break;
687
+ case 'desktop_exec': {
688
+ // An entity on ANOTHER machine driving this desktop. The MCP used to
689
+ // run `docker exec` locally, so an entity could only ever touch a
690
+ // desktop that happened to live on the node it was dispatched to.
691
+ // This is that same exec, arriving over the wire.
692
+ const argv = Array.isArray(cmd.input?.argv) ? cmd.input.argv : null;
693
+ if (!argv || argv.length === 0)
694
+ throw new Error('desktop_exec needs an argv array');
695
+ const timeoutMs = Math.min(Math.max(Number(cmd.input?.timeout_ms) || 60_000, 1_000), 10 * 60_000);
696
+ const res = await (0, creds_1.execInDesktopCapture)(provider, row, argv, timeoutMs);
697
+ // stdout is base64 because it is frequently a PNG. A non-zero exit is
698
+ // reported as a completed command carrying that code — the command
699
+ // ran; what it returned is the entity's problem to read.
700
+ await update(cmd.id, 'completed', {
701
+ stdout_b64: res.stdout.toString('base64'),
702
+ stderr: res.stderr.slice(0, 8000),
703
+ exit_code: res.exitCode,
704
+ });
705
+ return;
706
+ }
674
707
  }
675
708
  await update(cmd.id, 'completed', { log });
676
709
  }
@@ -5,3 +5,18 @@ export declare function stripMcpServers(raw: string): string;
5
5
  /** Run a command inside a desktop, streaming output to onLog. */
6
6
  export declare function execInDesktop(provider: DesktopProvider, row: DesktopRow, cmd: string[], onLog: (c: string) => void): Promise<void>;
7
7
  export declare function syncCredentials(provider: DesktopProvider, row: DesktopRow, onLog: (c: string) => void): Promise<void>;
8
+ /**
9
+ * Run a command inside a desktop and CAPTURE its output.
10
+ *
11
+ * execInDesktop streams to a log, which is right for an install and useless
12
+ * for a screenshot: the caller needs the bytes back, exactly, and a PNG does
13
+ * not survive being treated as UTF-8. This is the remote arm of the desktop
14
+ * MCP — an entity running on one machine driving a desktop on another — so
15
+ * the result has to cross the wire intact and a non-zero exit is a RESULT,
16
+ * not a transport failure.
17
+ */
18
+ export declare function execInDesktopCapture(provider: DesktopProvider, row: DesktopRow, cmd: string[], timeoutMs?: number): Promise<{
19
+ stdout: Buffer;
20
+ stderr: string;
21
+ exitCode: number;
22
+ }>;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.stripMcpServers = stripMcpServers;
37
37
  exports.execInDesktop = execInDesktop;
38
38
  exports.syncCredentials = syncCredentials;
39
+ exports.execInDesktopCapture = execInDesktopCapture;
39
40
  // Harness logins are COPIED into a desktop, never bind-mounted.
40
41
  //
41
42
  // Bind-mounting ~/.claude would hand the box write access to every transcript
@@ -132,3 +133,30 @@ async function syncCredentials(provider, row, onLog) {
132
133
  }
133
134
  }
134
135
  }
136
+ /**
137
+ * Run a command inside a desktop and CAPTURE its output.
138
+ *
139
+ * execInDesktop streams to a log, which is right for an install and useless
140
+ * for a screenshot: the caller needs the bytes back, exactly, and a PNG does
141
+ * not survive being treated as UTF-8. This is the remote arm of the desktop
142
+ * MCP — an entity running on one machine driving a desktop on another — so
143
+ * the result has to cross the wire intact and a non-zero exit is a RESULT,
144
+ * not a transport failure.
145
+ */
146
+ function execInDesktopCapture(provider, row, cmd, timeoutMs = 60_000) {
147
+ const args = (0, provider_1.buildExecArgs)(row, cmd, { cwd: '/work', env: {}, tty: false });
148
+ const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
149
+ return new Promise((resolve, reject) => {
150
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
151
+ env: process.env, windowsHide: true, timeout: timeoutMs,
152
+ });
153
+ const out = [];
154
+ let err = '';
155
+ child.stdout?.on('data', (b) => out.push(b));
156
+ child.stderr?.on('data', (b) => { err += b.toString('utf8'); });
157
+ child.on('error', reject);
158
+ child.on('close', code => resolve({
159
+ stdout: Buffer.concat(out), stderr: err, exitCode: code ?? -1,
160
+ }));
161
+ });
162
+ }
@@ -24,6 +24,25 @@ export declare function ensureDirs(desktopId: string): {
24
24
  confDir: string;
25
25
  workDir: string;
26
26
  };
27
+ export declare const DEFAULT_MAX_CONCURRENT_DESKTOPS = 2;
28
+ export declare function clampMaxDesktops(value: unknown): number;
29
+ /** Pushed in by the heartbeat. Lowering it never stops a running desktop —
30
+ * killing someone's live session to satisfy a number they just typed would
31
+ * be a worse surprise than being one over the cap for a while. */
32
+ export declare function setMaxConcurrentDesktops(value: unknown): void;
33
+ export declare function maxConcurrentDesktops(): number;
34
+ /** How many desktops are running right now, asked of the engine rather than
35
+ * tracked in a counter. A counter would drift the moment somebody stopped a
36
+ * container by hand, and drift upward means refusing to start anything. */
37
+ export declare function runningDesktopCount(rows: DesktopRow[]): Promise<number>;
38
+ /** Whether one more may start. Returns the reason when it may not, so the
39
+ * caller can say something better than "failed". */
40
+ export declare function desktopStartAllowed(rows: DesktopRow[], excludeId?: string): Promise<{
41
+ allowed: true;
42
+ } | {
43
+ allowed: false;
44
+ reason: string;
45
+ }>;
27
46
  export declare function startDesktopManager(): void;
28
47
  export declare function stopDesktopManager(): void;
29
48
  /** Bring a desktop up and wait for it, for the run path. Returns the row when
@@ -33,11 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_MAX_CONCURRENT_DESKTOPS = void 0;
36
37
  exports.reconcileAction = reconcileAction;
37
38
  exports.listDesktops = listDesktops;
38
39
  exports.setStatus = setStatus;
39
40
  exports.allocateVncPort = allocateVncPort;
40
41
  exports.ensureDirs = ensureDirs;
42
+ exports.clampMaxDesktops = clampMaxDesktops;
43
+ exports.setMaxConcurrentDesktops = setMaxConcurrentDesktops;
44
+ exports.maxConcurrentDesktops = maxConcurrentDesktops;
45
+ exports.runningDesktopCount = runningDesktopCount;
46
+ exports.desktopStartAllowed = desktopStartAllowed;
41
47
  exports.startDesktopManager = startDesktopManager;
42
48
  exports.stopDesktopManager = stopDesktopManager;
43
49
  exports.ensureRunning = ensureRunning;
@@ -118,6 +124,70 @@ function ensureDirs(desktopId) {
118
124
  fs.mkdirSync(workDir, { recursive: true, mode: 0o700 });
119
125
  return { confDir, workDir };
120
126
  }
127
+ // How many desktops may be RUNNING on this machine at once.
128
+ //
129
+ // There was never a ceiling here, and the failure it allows is not subtle:
130
+ // four containers each sized for a browser will take a Mac mini down, and
131
+ // when they do they take the agent runs and the flow runs with them. Its own
132
+ // pool, like flows and agent runs, because the three compete for the same box
133
+ // but answer to different people.
134
+ //
135
+ // Absent means "keep what you had", never 0 — a server that has not had the
136
+ // migration must not be able to stop every desktop on every machine.
137
+ exports.DEFAULT_MAX_CONCURRENT_DESKTOPS = 2;
138
+ const MAX_MAX_CONCURRENT_DESKTOPS = 16;
139
+ let maxDesktops = exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
140
+ function clampMaxDesktops(value) {
141
+ if (typeof value !== 'number' && typeof value !== 'string')
142
+ return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
143
+ if (typeof value === 'string' && value.trim() === '')
144
+ return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
145
+ const n = typeof value === 'number' ? value : Number(value);
146
+ if (!Number.isFinite(n))
147
+ return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
148
+ return Math.min(MAX_MAX_CONCURRENT_DESKTOPS, Math.max(1, Math.floor(n)));
149
+ }
150
+ /** Pushed in by the heartbeat. Lowering it never stops a running desktop —
151
+ * killing someone's live session to satisfy a number they just typed would
152
+ * be a worse surprise than being one over the cap for a while. */
153
+ function setMaxConcurrentDesktops(value) {
154
+ const next = clampMaxDesktops(value);
155
+ if (next === maxDesktops)
156
+ return;
157
+ console.log(`[desktops] desktops at once: ${maxDesktops} -> ${next}`);
158
+ maxDesktops = next;
159
+ }
160
+ function maxConcurrentDesktops() { return maxDesktops; }
161
+ /** How many desktops are running right now, asked of the engine rather than
162
+ * tracked in a counter. A counter would drift the moment somebody stopped a
163
+ * container by hand, and drift upward means refusing to start anything. */
164
+ async function runningDesktopCount(rows) {
165
+ const provider = await (0, docker_1.getProvider)();
166
+ if (!provider)
167
+ return 0;
168
+ let count = 0;
169
+ for (const row of rows) {
170
+ try {
171
+ const actual = await provider.inspect(row);
172
+ if (actual?.running)
173
+ count++;
174
+ }
175
+ catch { /* unknown state counts as not running; the reconcile will fix it */ }
176
+ }
177
+ return count;
178
+ }
179
+ /** Whether one more may start. Returns the reason when it may not, so the
180
+ * caller can say something better than "failed". */
181
+ async function desktopStartAllowed(rows, excludeId) {
182
+ const running = await runningDesktopCount(rows.filter(r => r.id !== excludeId));
183
+ if (running < maxDesktops)
184
+ return { allowed: true };
185
+ return {
186
+ allowed: false,
187
+ reason: `This machine is already running ${running} desktop${running === 1 ? '' : 's'}, `
188
+ + `its limit. Stop one, or raise the limit on the machine's page.`,
189
+ };
190
+ }
121
191
  let timer = null;
122
192
  let inflight = false;
123
193
  async function tick() {
@@ -136,6 +206,15 @@ async function tick() {
136
206
  continue;
137
207
  try {
138
208
  if (action === 'start') {
209
+ // Autostart is the quiet path to blowing the cap: a machine that
210
+ // reboots would bring every desktop back at once regardless of what
211
+ // the box can take. Over the line, it defers and says so rather than
212
+ // failing — nothing is wrong with the desktop, there is just no room.
213
+ const room = await desktopStartAllowed(rows, row.id);
214
+ if (!room.allowed) {
215
+ await setStatus(row.id, { status: 'stopped', status_message: room.reason });
216
+ continue;
217
+ }
139
218
  await provider.start(row);
140
219
  await setStatus(row.id, { status: 'running', status_message: null });
141
220
  }
@@ -196,6 +275,14 @@ async function ensureRunning(desktopId, timeoutMs = 60_000) {
196
275
  return row;
197
276
  if (!actual)
198
277
  return null; // never created / pruned: cannot start
278
+ // At the cap, the run falls back to the host rather than starting an
279
+ // eleventh container. Returning null is how this function already says
280
+ // "could not start it" and the caller already handles it.
281
+ const room = await desktopStartAllowed(rows, row.id);
282
+ if (!room.allowed) {
283
+ console.warn(`[desktops] not starting ${row.id} for a run — ${room.reason}`);
284
+ return null;
285
+ }
199
286
  const deadline = Date.now() + timeoutMs;
200
287
  try {
201
288
  await provider.start(row);
@@ -164,10 +164,24 @@ async function connect() {
164
164
  catch {
165
165
  return;
166
166
  }
167
+ // Every way a bridge can fail used to fail SILENTLY: the viewer's socket
168
+ // stayed open, noVNC went on reporting 'connected', and the last frame sat
169
+ // there looking live while every keystroke was dropped on the floor. A
170
+ // frozen picture you cannot tell from a working one is the worst possible
171
+ // failure, so each of these now says so and the viewer is disconnected.
172
+ const gone = (viewerId, reason) => {
173
+ bridges.get(viewerId)?.destroy();
174
+ bridges.delete(viewerId);
175
+ if (sock.readyState === ws_1.default.OPEN) {
176
+ sock.send(JSON.stringify({ type: 'gone', viewerId, reason }));
177
+ }
178
+ };
167
179
  if (msg.type === 'attach') {
168
180
  const row = (await (0, manager_1.listDesktops)()).find(d => d.id === msg.desktopId);
169
- if (!row?.vnc_port)
181
+ if (!row?.vnc_port) {
182
+ gone(msg.viewerId, 'desktop is not running');
170
183
  return;
184
+ }
171
185
  // Loopback only - the container published its RFB on 127.0.0.1 and this
172
186
  // process is the only thing on the machine that reaches it.
173
187
  const tcp = net.connect(row.vnc_port, '127.0.0.1');
@@ -178,15 +192,25 @@ async function connect() {
178
192
  }));
179
193
  }
180
194
  });
181
- const forget = () => { bridges.delete(msg.viewerId); };
182
- tcp.on('close', forget);
183
- tcp.on('error', forget);
195
+ // Refused connection (container up, x11vnc not listening) and a bridge
196
+ // that dies mid-stream are different words but the same outcome for the
197
+ // person watching: there is no screen any more, and they must be told.
198
+ tcp.on('error', () => gone(msg.viewerId, 'lost the desktop connection'));
199
+ tcp.on('close', () => gone(msg.viewerId, 'the desktop closed the connection'));
184
200
  bridges.get(msg.viewerId)?.destroy();
185
201
  bridges.set(msg.viewerId, tcp);
186
202
  return;
187
203
  }
188
204
  if (msg.type === 'data' && msg.b64) {
189
- bridges.get(msg.viewerId)?.write(Buffer.from(msg.b64, 'base64'));
205
+ const bridge = bridges.get(msg.viewerId);
206
+ // Input for a bridge that no longer exists. Dropping it quietly is how
207
+ // "take control does nothing" happens: the pointer never moves and the
208
+ // viewer has no idea why.
209
+ if (!bridge) {
210
+ gone(msg.viewerId, 'no longer connected to the desktop');
211
+ return;
212
+ }
213
+ bridge.write(Buffer.from(msg.b64, 'base64'));
190
214
  return;
191
215
  }
192
216
  if (msg.type === 'wake') {
@@ -0,0 +1,8 @@
1
+ type EngineModule = typeof import('@addai/node-flows');
2
+ /** Load the engine once and reuse it. The promise is cached rather than the
3
+ * module, so concurrent first calls share one load instead of racing. */
4
+ export declare function loadEngine(): Promise<EngineModule>;
5
+ /** Has the engine been loaded already? Lets the capabilities probe report the
6
+ * sandbox without forcing a load on a machine that does not run flows. */
7
+ export declare function engineLoaded(): boolean;
8
+ export {};
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ // Loading an ESM package from a CommonJS daemon.
3
+ //
4
+ // @addai/node-flows is ESM and its sandbox does a top-level `await
5
+ // import('isolated-vm')` — the optional native isolate, probed at load. This
6
+ // daemon compiles to CommonJS, and `require()` refuses an ESM graph with
7
+ // top-level await outright (ERR_REQUIRE_ASYNC_MODULE). So the engine can only
8
+ // ever be loaded asynchronously.
9
+ //
10
+ // The `new Function` is not a trick for its own sake: TypeScript with
11
+ // `module: CommonJS` rewrites a plain `import()` into `require()`, which is
12
+ // precisely the call that fails. Hiding the import inside a function body the
13
+ // compiler will not touch is what keeps it a real dynamic import at runtime.
14
+ //
15
+ // Loading is also lazy on purpose. A machine that never runs flows should
16
+ // never pay to load the engine, and on a machine where the native sandbox
17
+ // failed to build, the failure should surface when someone turns flows on —
18
+ // not as a daemon that will not start.
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.loadEngine = loadEngine;
21
+ exports.engineLoaded = engineLoaded;
22
+ const dynamicImport = new Function('specifier', 'return import(specifier);');
23
+ let cached = null;
24
+ /** Load the engine once and reuse it. The promise is cached rather than the
25
+ * module, so concurrent first calls share one load instead of racing. */
26
+ function loadEngine() {
27
+ if (!cached) {
28
+ cached = dynamicImport('@addai/node-flows').catch((err) => {
29
+ // Clear the cache so a later attempt can retry — a transient failure
30
+ // (a half-written install, say) must not poison the process for good.
31
+ cached = null;
32
+ throw new Error(`The flows engine could not be loaded: ${err.message}. `
33
+ + 'Reinstall @addai/node, or turn flows off for this machine.');
34
+ });
35
+ }
36
+ return cached;
37
+ }
38
+ /** Has the engine been loaded already? Lets the capabilities probe report the
39
+ * sandbox without forcing a load on a machine that does not run flows. */
40
+ function engineLoaded() { return cached !== null; }
@@ -0,0 +1,40 @@
1
+ import type { HandlerRegistry } from '@addai/node-flows';
2
+ /** One flow run, as the claim RPC hands it over. */
3
+ export interface FlowRunRow {
4
+ id: string;
5
+ flow_id: string | null;
6
+ aiflow_version_id: string | null;
7
+ workspace_id: string | null;
8
+ status: string;
9
+ nodes: any[] | null;
10
+ edges: any[] | null;
11
+ trigger_data: Record<string, unknown> | null;
12
+ node_outputs: Record<string, unknown> | null;
13
+ current_node_id: string | null;
14
+ execution_target: string;
15
+ is_test: boolean | null;
16
+ /** Set when the cloud handed back a run that had paused. Null on a fresh
17
+ * run, which starts at its trigger. */
18
+ resume_from_node_id: string | null;
19
+ priority: number | null;
20
+ resolved_variables: unknown;
21
+ }
22
+ /**
23
+ * The handler registry this machine runs a flow with.
24
+ *
25
+ * Built once per run so the proxy closes over that run's id — the server
26
+ * authorises each proxied node by checking this daemon still holds the claim
27
+ * on it.
28
+ */
29
+ export declare function buildRegistry(run: FlowRunRow): Promise<HandlerRegistry>;
30
+ /**
31
+ * Write one node event to the same place a cloud run writes it.
32
+ *
33
+ * Not optional polish. If a run on this machine is not as legible in the run
34
+ * viewer as a cloud run, self-hosting becomes the option nobody can debug, and
35
+ * people go back to the cloud for reasons that have nothing to do with where
36
+ * the work belongs.
37
+ *
38
+ * Fire-and-forget on purpose: a lost log line must never fail a flow.
39
+ */
40
+ export declare function reportNodeLog(run: FlowRunRow, node: any, status: 'success' | 'error', output: unknown): void;